diff --git a/.github/ISSUE_SPECS/1584-subscribe-to-an-effects-observation-tap-with-a-transaction-edit.md b/.github/ISSUE_SPECS/1584-subscribe-to-an-effects-observation-tap-with-a-transaction-edit.md index 1fd3833fa..de9865806 100644 --- a/.github/ISSUE_SPECS/1584-subscribe-to-an-effects-observation-tap-with-a-transaction-edit.md +++ b/.github/ISSUE_SPECS/1584-subscribe-to-an-effects-observation-tap-with-a-transaction-edit.md @@ -584,3 +584,13 @@ shipped module sha256 is `179a933a...c3c1ae`; no export, layout or JS file chang arm was sent and ignores an `OBSERVATION_ENDED` event of a commit at or before it: that event ended an earlier arm of the same tap. `ControlTransactionQueue.readCommittedRevision()` reads it from the core. + +## Amendment (controls coordinator, batch 13b) + +- **The C ABI observe edit uses the core's two calls.** The edit of D1-D5 resolves its tap with + `SessionState::resolve_observation_key` (#1677: the stable key on the newest committed plan, never + a plan position) and admits and queues its changes with + `SessionState::admit_and_push_observation_batch` (#1676: one naming per tap, the key-count cap, + then the room, whole or nothing), both in `crates/control-plane/src/control.rs`. The browser's + record calls the same two, so the cap and the stable key hold alike on both hosts. The edit adds + no admission or key lookup of its own. diff --git a/.github/ISSUE_SPECS/1635-retire-the-protocol-transport-position.md b/.github/ISSUE_SPECS/1635-retire-the-protocol-transport-position.md index e223b174c..2fa26781c 100644 --- a/.github/ISSUE_SPECS/1635-retire-the-protocol-transport-position.md +++ b/.github/ISSUE_SPECS/1635-retire-the-protocol-transport-position.md @@ -146,3 +146,61 @@ Checked against `12d4a4f9d`. - **Anchors moved.** The stored position is `transport_position` (`crates/host-core/src/control_provider.rs:339`, seed `:405`, set `:1186-1187`, snapshot `:1179`). #1315 has landed and kept `TRANSPORT_SET`. Decision 17 does not touch the transport. Batch P1, ready. + +## Decision record: #1635 (batch 13b) + +**Shipped.** `TRANSPORT_SET` field 2, `TransportSnapshot` field 2 and `TransportStateEvent` field 3 +(the stored position) are retired. Each message has a `RETIRED` spec in `crates/protocol/src/schema.rs` +(`transport_set`, `transport_snapshot`, `transport_state_event`) and its decoder reads through +`schema_spec_retiring`, so a retired field is refused in either flag form: optional as +`InvalidTlv` (`MALFORMED_FRAME`), mandatory as `UnknownRequiredField` +(`UNKNOWN_REQUIRED_FIELD`). The remaining field IDs keep their numbers (snapshot 1 and 3; event 1, 2, +4 and 5). `TransportSetRequest`, `TransportSnapshot`, `TransportStateEvent`, +`ReliablePayload::TransportState` and `ReliableSlot::transport_state` lose `position`; the encoded +lengths are 16 (set), 48 and 64 (event without and with origin; were 64 and 80). The provider trait +keeps its shape; host-core's provider and `MockProvider` lose `transport_position`. D5: a set with a +position is refused at decode, before any change and before any event reservation, so no ack +precedes a drop. + +**Re-pins** (reason "#1635 retires the stored transport position"): `COMPLETE_SCHEMA_HASH` +`0xcc9a_7bcc_502c_4936` to `0x3316_54b3_24d0_76e8` with its history line in +`crates/conformance/src/protocol_corpus.rs`; the two parity strings in +`scripts/check-protocol-wasm-parity.sh`; `fuzz/corpus/complete-schema-manifest.md` (hash, history +line, and the prose that named an optional transport position); `docs/CONTROL_PROTOCOL_CONFORMANCE.md`; +the C ABI vectors with a transport frame in `crates/capi/src/runtime/tests.rs` (the two snapshot +vectors, `TRANSPORT_20`/`TRANSPORT_21`, the `RESPONSES` snapshot, and the three event vectors), +re-derived mechanically by deleting the position field and fixing the payload length and field count. +The corpus frame count stays 46. + +Integrated in batch 13b: one re-pin from `0xcc9a_7bcc_502c_4936` to `0x0c71_96f8_6abe_6d57` for #1635 and #1678 together. + +**Departures.** + +- D3 says `retired_code_rows` gains one row per retired field. A `RetiredCodeRow` is a command frame + an endpoint answers, so only `TRANSPORT_SET` field 2 can be a row. It has two rows, one per flag + form (`retired.transport_set_field_2.mandatory`, `.optional`). The snapshot and event fields are + refused by `b3a_transport_goldens_truncation_and_direct_codec_are_strict` in both flag forms. +- Gate 1's status: the refusals answer `UNKNOWN_REQUIRED_FIELD` (mandatory) and `MALFORMED_FRAME` + (optional), the statuses the retiring machinery maps to for the track fields, not `INVALID_FIELD`. +- Corpus `counter_command_frame` now gives `TRANSPORT_SET` an exact revision 7 (a set requires one). +- Anchors had moved (queue.rs `:61`/`:138`, host-core `:337`); followed the code. Mechanical edits + outside the listed paths: `hosts/host-web/tests/protocol_cross_host.rs` (the `TransportSetRequest` + literal and the unused `SampleTime` import). The SDK mirrors no transport frame; no SDK input changed. + +**Gates.** + +1. Refused: `a_transport_set_with_the_retired_position_changes_nothing` (both flag forms: status, revision, + session, reliable lane and `TRANSPORT_GET` state unchanged) and + `retired_and_console_refused_codes_meet_their_conformance_rows` (rows 16+4+7+13+1+4+10+2+1). + Decoder: `b3a_transport_goldens_truncation_and_direct_codec_are_strict`. +2. State still works: `a_transport_set_answers_the_state_and_queues_one_event_with_it`, + `b3a_transport_btlv_is_typed_idempotent_and_emits_reliable_state` (event golden re-pinned, 64 bytes). +3. `cargo test --locked -p protocol` (163), `-p conformance` (`frozen_corpus_bytes_and_typed_decoders_are_unchanged`), + `-p host-core --lib control_provider`, `-p capi --lib runtime` (114), all green; + `bash scripts/check-protocol-wasm-parity.sh` ok (simd128); `scripts/run-protocol-allocation-audit.sh` + ok; `audit capi`: zero allocations, locks and syscalls, `pcm_digest` `bf7dcedd1d92621f`, equal to base. +4. `scripts/check-cross-targets.sh` was not run here (coordinator's batch gate); the change adds no + memset-style code. + +**Successor.** `docs/CONTROL_PROTOCOL_SEMANTICS.md:23` still says "locating transport cancels +nothing"; reword when that file is next in scope (outside this slice's authorized paths). diff --git a/.github/ISSUE_SPECS/1636-retire-the-span-kinds-and-sample-rate-no-producer-uses.md b/.github/ISSUE_SPECS/1636-retire-the-span-kinds-and-sample-rate-no-producer-uses.md index 4e3248c83..80b0b8724 100644 --- a/.github/ISSUE_SPECS/1636-retire-the-span-kinds-and-sample-rate-no-producer-uses.md +++ b/.github/ISSUE_SPECS/1636-retire-the-span-kinds-and-sample-rate-no-producer-uses.md @@ -165,3 +165,39 @@ Checked against `12d4a4f9d`. add `crates/controls/src/effect_rows.rs` to the authorized paths. - **Anchors.** `AutomationRate` is now `crates/effect-contract/src/lib.rs:415`; re-read the rest at filing. Batch P1, ready. + +## Decision record: #1636 (batch 13b) + +**Shipped.** `AutomationSpanKind` is `{Point=1}` and `AutomationRate` is `{Block=2, None=3}`; +`from_raw` refuses 2 to 4 and 1 (comments name the values retired, never reallocated). +`automation_segment_value` is deleted; `valid_runtime_span` checks only a `Point`; the descriptor +check (`parameter_automation_smoothing_valid`) accepts only `Block` for an automatable parameter; +`validate_automation_block` no longer re-checks the kind. The metadata tool's rate name and the +independent gate's `RATES` lose `sample`; the gate's self-test gains the mutation "the retired +sample automation rate returns". `controls`' `parameter_class` loses its `Sample` arm and the test +that existed only for it. The conformance reference mock keeps only `Point` spans: its per-lane +`active` span state is gone, so its state payload is 20 bytes per lane (was 56). The randomized +generator draws only `Point` spans at the block's first sample. The delay's malformed-span test +uses a `Point` whose end sample differs from its start. Fixtures that declared `Sample` declare +`Block` (`live_window.rs` loses its `Sample` row: `NOT_LIVE` is 2). The policy manifest row, the +docs (`EFFECT_CONTRACT_V1.md`, `CONTROL_PROTOCOL_SEMANTICS.md`) and two comments are updated. + +**Departures.** +- The protocol half was already gone (amendment): no `ParameterAutomationRate`, no parameter + descriptor frame, so D3 has nothing to re-pin. `COMPLETE_SCHEMA_HASH` and the wasm-parity strings + do not move (`check-protocol-wasm-parity.sh` passes unchanged), and `retired_code_rows` gains no + row: there is no frame that can carry rate 1. Gate 1 is held by `retired_automation_values_are_refused` + and the truth table in `crates/effect-contract/src/lib.rs`. +- Mechanical edits outside the listed paths: `crates/effect-contract/tests/live_window.rs`, + `crates/host-core/src/prepare.rs` (a comment), `docs/CONTROL_PROTOCOL_SEMANTICS.md`. +- The registry has no rate line any more, so `CONTROL_PROTOCOL_REGISTRY.md` is unchanged. + +**Gates.** `cargo test --locked` for effect-contract, effect-compiler, conformance (randomized +included), controls, delay, parameter-metadata, compressor, gate-expander, soft-clip, +true-peak-limiter, parametric-eq, multiband-compressor, transient-shaper, protocol and host-core +(`--features test-support`) and `capi --lib runtime`: all pass. `check-effect-runtime-policy.sh`, +`test-effect-runtime-policy.sh`, `check-protocol-wasm-parity.sh`, `check-sdk-generated.sh`, +`check-workspace-policy.sh`, `check-parameter-metadata-v1.py --self-test`: pass. Clippy +`-D warnings` (all-targets, all-features) on the touched crates and `cargo fmt --all`: clean. +`audit capi`: `pcm_digest` `bf7dcedd1d92621f` (same as base), zero allocations, locks and +syscalls; protocol allocation audit ok. diff --git a/.github/ISSUE_SPECS/1637-compile-stored-automation-into-per-cell-events.md b/.github/ISSUE_SPECS/1637-compile-stored-automation-into-per-cell-events.md index 1b55eaf20..2fe1933ab 100644 --- a/.github/ISSUE_SPECS/1637-compile-stored-automation-into-per-cell-events.md +++ b/.github/ISSUE_SPECS/1637-compile-stored-automation-into-per-cell-events.md @@ -114,7 +114,9 @@ it yet; #1645's order check (`value_at`) and #1646 and #1647 are its first users - The cursor advances one segment per boundary crossed and never moves backwards except at a seek or a reposition. - Bound: at most `⌈q/G⌉ + 1` grid events, `⌈q/64⌉ + 1` jumps (#1626's spacing rule) and one - seek: `2⌈q/64⌉ + 3` per cell and block. + seek: `2⌈q/64⌉ + 3` per cell and block, plus one for a `no_restart` cell (a jump deferred from + the previous block); `CellProgram::max_events(q)` returns it. Amended at attempt 2, which has + the proof (decision record, "Attempt 2", finding 1). - **D5. Builder.** `ProgramBuilder` takes neutral cell descriptions (segments and D2's parameters; no session type) and reports the exact bytes it will allocate (`bytes(&cells) -> u64`) before it allocates them with fallible reservations. It refuses, typed, a description that breaks D2's @@ -184,7 +186,8 @@ it yet; #1645's order check (`value_at`) and #1646 and #1647 are its first users offset and at offset 0; a `Word` key's jump ramps over the word passed for the block, so two blocks with different words give two jump lengths. 3. **Bound.** An adversarial table (a discontinuity every 64 samples, every segment moving) and a - seek in the block give at most `2⌈q/64⌉ + 3` events per block at `q` in {64, 100, 128, 1024}. + seek in the block give at most `2⌈q/64⌉ + 3` events per block at `q` in {64, 100, 128, 1024}, + plus one for a `no_restart` cell (D4, amended at attempt 2). 4. **Quantum and seek invariance.** The same table rendered through mappings of quantum 128 and quantum 100, and with one seek to the same timeline sample landing at two different render samples, gives the same sequence of `(node time, kind, ramp, value bits)` events from the first @@ -247,3 +250,172 @@ Checked against `12d4a4f9d`. so nothing else changes. - **OQ1 = C** adds no evaluator work: the caller adds the offset after `value_at` (#1650 D6, #1658). + +## Decision record: #1637 (batch 13b) + +**Shipped.** `crates/automation` (package, lib and directory `automation`; `#![no_std]` + `alloc` +for the builder; depends on `math` only): `value_at`, `ProgramBuilder` (neutral `CellDescription`, +typed `BuildError`, `bytes()` report), `CellProgram`, `CellState`, `NodeSpan`, `CellEvents`, and +`automation::corpus` (six scalar G5 cases and their pins). The G5 block is appended after the +builtins tail in `tools/wasm-gate-corpus`, scalar (not width dependent); `g5_native_corpus.rs` +checks its layout and `g6_full_corpus_ftz.rs` counts it as width independent. Realtime regions +mark `value_at` (`evaluate.rs`) and the event generator (`events.rs`). + +**Departures from the filed text (each a code-truth or correctness call).** + +1. **The jump-length key.** The amendment replaces `JumpWord` with `LiveRampRow` resolved by + `controls::LiveRamps::for_row`. `effect-contract` is `std` and pulls `engine` and `lane` + (the AVX2-pinned crate), and `controls` pulls `session`; either would break D1 ("`math` alone"). + `JumpLength` is `Block | Fixed(u32)`: a `Block` cell takes one resolved `u32` per block + (`CellEvents::new(..., jump)`). The caller resolves the cell's row through `for_row` (which owns + the polarity doubling and every other row rule), so no row's rule is written here and the program + still stores no copy of `J`. `jump_lengths: [u32; 3]` became that single `jump: u32`. +2. **A grid or jump ramp never aims past the next discontinuity.** D4 says a grid event aims at + `value_at(completion(τ, L))`. Taken literally, a grid event up to 63 samples before an off-grid + step aims at the post-step value, the step's own `Jump` then has the same target and change-only + drops it, so the step lands on the grid's ramp and not at its exact offset (gate 2 cannot hold). + The aim time is `min(completion, d - 1)` for the first discontinuity `d` in `(τ, completion]`; + the `Jump` at `d` retargets from there. The same clamp applies to a `Jump` and a ramped seek + landing. Test `jump_at_off_grid_discontinuity_at_its_exact_offset` is red without it. +3. **The discontinuity list is built and stored.** The cursor indexes a per-cell list of + discontinuity times (value just before the time differs from the value at it, bit for bit; built + by the builder, charged in `bytes`), not the raw segment boundaries. Scanning raw boundaries for + "the next discontinuity" is unbounded when continuous segments are dense (one-sample segments); + the list makes every lookup `O(1)` and the reposition one binary search. +4. **`no_restart` also holds grid events.** A jump off the grid starts a ramp that a later grid + event (not completing before it) would restart; for a `no_restart` cell every grid event inside + a ramp in flight is `held`. The jump itself is deferred to `completion + 1` (`End`) or + `completion` (`After`), carried in `CellState` across blocks. +5. **Held bookkeeping.** `CellState::held` means "a held value is outstanding at the stage": the + next retarget or held event emits even when its value equals the target, once, so the stage's + remembered value is refreshed (otherwise a curve that leaves the target and returns inside a + long ramp leaves a stale value). `CellState` is 32 bytes (limit 48). The spec's "last seek + boundary" field has no user and is not stored. +6. **`NodeSpan::new` is fallible** (`StepOutsideBlock` when the step offset is not below `frames`), + so a seek cannot be silently dropped in render. A fresh `CellState` has no target, so its first + retarget always emits; `reposition(&program, t)` places the cursor at the first discontinuity at + or after `t`, keeps the target, and drops the in-flight ramp, any deferred jump and an outstanding + held event (batch 13b review: the earlier "cursor only" wording left out the held event), so a + retarget to the value the cell already targets then emits nothing. +7. **Builder limits** beyond D5: `start < end`, every time below `2^53` (the evaluator's exactness + bound), finite values, at least one segment per cell. `value_at` on an empty table returns `0.0` + and never panics. +8. **Realtime floors.** `scripts/check-realtime-policy.sh` is gone; `marked_files_floor` and + `marked_regions_floor` in `tools/realtime-policy/src/policy.rs` were 28 and 129 but the tree + held 29 and 141 before this change (the floors had fallen behind). They are now 31 and 143, the + tree's counts with this crate's two files and two regions. + +**Observation for successors.** A seek `Set` in `Exact` mode clears the in-flight ramp in the +state, so the stage must treat `Set` as an immediate assignment. `CellState::hold_until` takes the completion node time in the cell's own `Completion` +convention; on a `no_restart` cell it must not be earlier than the ramp in flight (the caller +does not restart a busy lane), or a deferred jump lands late (attempt-3 review, MINOR). +A seek landing in `Ramp` mode restarts a busy `no_restart` lane, as D4's `Set` rule says, with no +`no_restart` exception. #1646 and #1647 use `no_restart = false`. The first issue that automates +a `no_restart` target (delay time) decides whether a seek landing waits for the lane. + +**Gates and tests.** + +| Gate | Test (`cargo test --locked -p automation`) | Result | +|---|---|---| +| 1 Evaluation | `evaluate`: `hold_rule_before_inside_after_and_between`, `step_end_value_applies_only_from_its_end`, `linear_at_zero_half_and_one`, `exponential_is_scalar_f64_through_math_pow` (bit equal to the `f64` formula, differs from the `f32` one), `empty_table_is_zero` | pass | +| 2 Event rules | `events`: `grid_events_sit_on_node_time_multiples_of_64` (quantum 100, start -37), `jump_at_off_grid_discontinuity_at_its_exact_offset`, `end_and_after_completions`, `change_only`, `held_inside_a_reported_ramp_and_the_grid_resumes`, `a_held_value_back_at_the_target_still_emits_once`, `no_restart_jump_waits_for_the_ramp_to_end`, `set_at_a_seek_step_for_exact_and_a_ramped_jump_for_ramp` (offsets 0 and 37), `set_always_emits`, `a_block_keyed_jump_ramps_over_the_blocks_length`, `a_fixed_key_ignores_the_blocks_length`, `a_step_outside_the_block_is_refused` | pass | +| 3 Bound | `events_per_block_are_bounded` (a discontinuity every 64 samples, every segment moving, seeks mid-block, q in {64, 100, 128, 1024}, at most `2⌈q/64⌉ + 3`) | pass | +| 4 Invariance | `quantum_invariance` (128 vs 100, both modes), `seek_invariance` (one timeline sample reached at three render positions after three histories, both modes) | pass | +| 5 No allocation | `allocation::ten_thousand_blocks_allocate_nothing` (0 allocations, 0 frees, seeks every 97th block); `build::bytes_report_equals_the_live_bytes` (`requested - released == bytes()`, 3 reservations, 0 reallocations); `build::refusals_are_typed`, `build::discontinuities_are_the_steps_of_the_hold_rule` | pass | +| 6 G5 | `cargo test --release -p wasm-gates --test g5_native_corpus --features math/lane` (7 tests incl. `g5_delegated_cases_use_the_owning_crates_pins` for the block, `g5_native_digests_match_pins`); wasm leg `scripts/run-wasm-gates.sh --without-v8-spill --without-native`; `automation::corpus` self-check `corpus::cases_produce_events_and_match_their_pins` | native pass; wasm leg pass (`wasm gates: ok`, no mismatch) | +| 7 Policy | `./target/release/realtime_policy` (143 regions in 31 files), `scripts/check-workspace-policy.sh` | ok | + +**AArch64** cannot run on this host. The pins are scalar `f64` through `math::pow` (no fused +operations, no lane type), generated on x86-64, and must match on the `aarch64-release` job. + +**Measured.** `Segment` 32 bytes, `CellState` 32 bytes (assertion at most 48). The six corpus cases +yield 20+ events each (case 5, `no_restart`, 42). + +### Attempt 2 + +The adversarial verdict on attempt 1 (`63741fb3c`) failed it on two MEDIUM, two LOW and one INFO +finding. Each is fixed below with the test that is red without the fix. + +1. **MEDIUM: the bound `2⌈q/64⌉ + 3` was false for a `no_restart` cell.** A jump deferred from + the previous block (its ramp, or a ramp the caller reported through `hold_until`, ran past the + discontinuity) fires in the run before a seek step, on top of that run's own discontinuities. + A tighter rule is not possible without changing what the cell renders: the deferred jump and + the next discontinuity are both real value changes (in the verifier's case value 2 holds for + three samples, 131 to 133, before the step to 3 at 134). Proof of the new bound + (`CellProgram::max_events` documents it): a block is at most two runs, `a + b = q - 1`; each run + holds at most `⌈run/64⌉` grid points (`G >= 64`) and `⌈run/64⌉` discontinuities (spacing at + least 64), and `⌈a/64⌉ + ⌈b/64⌉ <= ⌈q/64⌉ + 1`; the seek adds one; only a `no_restart` cell + defers, at most one deferral is outstanding (later covered discontinuities fold into it), a seek + clears it, so it adds one jump to the run before the step only. The bound is `2⌈q/64⌉ + 3`, and + `2⌈q/64⌉ + 4` for a `no_restart` cell; the verifier's case reaches `+ 4` exactly, so it is tight. + The per-cell capacity follows it: new `CellProgram::max_events(frames)`. The verifier's fuzzer, + with its bound read from `max_events`, over 173,438 spaced tables (seeks, caller ramps, forward + node-time moves) finds no excess, and its quantum-invariance and jump-oracle checks still pass. + - **The spacing dependency.** The 64-sample spacing is a session contract: #1626 D3 refuses + closer discontinuities (`automation.discontinuity_spacing`, `crates/session/src/validate.rs`, + `docs/SESSION_SCHEMA_V1.md`) with the same hold-rule definition the builder uses. So the + builder refuses them again, typed: `BuildError::Spacing { cell, time }` (the later + discontinuity of the first close pair), with `automation::DISCONTINUITY_SPACING = 64`. A valid + session never meets it; a caller that bypasses the session cannot break the bound silently. + The crate docs state the bound and the rule. Corpus case 2 had discontinuities at 3190 and + 3250 (60 apart), a table the session refuses; its step now starts at 3180, so **case 2's G5 + pin moved** (regenerated with `print_pins`). With attempt 1's `events.rs` and the new table the + regenerated digest is the same, so the move comes from the table alone; no event-rule change + below moves any of the six pins. + - Tests: `events::events_per_block_are_bounded` now asserts the stated bound for each cell + (`max_events(q)` equals `2⌈q/64⌉ + 3 + no_restart`, every block at most that, every offset in + the block), over a plain cell and a `no_restart` cell with caller ramps, seeks and + q in {1, 63, 64, 100, 128, 1024}; `events::a_carried_deferral_reaches_the_no_restart_bound` is + the verifier's case (six events, equal to `max_events(64)`), red against attempt 1's `+ 3`; + `build::closer_discontinuities_are_refused` (163 refused, 164 built, a step's own end counts), + red on attempt 1 (no refusal). +2. **MEDIUM: an offset outside the block.** A deferral below the span start (the caller moved node + time forward with no seek step) gave a negative distance that `as u32` wrapped. A deferred jump + is a jump already owed, so it is now **due at the run's first sample**, aimed at the curve + there (`target_at`), not dropped: dropping it would leave the old target until the next grid + event. Inside a run a deferral is always ahead, so only that case changes. The distance is now + converted with `u32::try_from`. Discontinuities behind the run are still dropped (the next grid + event retargets along the curve), now with one `partition_point` over the rest of the list + (`skip_behind`) instead of a linear loop. Test + `events::a_deferral_behind_a_forward_span_is_due_at_its_start` (one `Jump` at offset 0 to 3.0), + red on attempt 1 (`offset: 4294966927`). +3. **LOW: a second owner of the G5 pins.** `tests/corpus.rs` no longer compares digests; + `g5_native_digests_match_pins` is the single owner. The non-vacuity check stays as + `corpus::cases_produce_events`. +4. **LOW: clamp plus change only dropped a real jump.** When `value(d - 1) == value(d)` but the + left limit at `d` differs, the grid ramp aimed at `d - 1` has the jump's target, and change only + dropped the jump while that ramp was still short of it. `jump_event` now emits whenever the ramp + in flight completes after `completion(d, J)`, even with an unchanged value. Test + `events::a_jump_to_the_in_flight_target_still_lands_at_its_time` (the verifier's table: a `Jump` + at 32 to 32.0, ramp 0), red on attempt 1. +5. **LOW: render-reachable code outside the regions.** The `events.rs` region now begins at + `NodeSpan` and covers `NodeSpan::new`, `CellState`, `to_cursor` and `completion_of`; a new + region in `program.rs` covers the `CellProgram` accessors, `max_events`, `Program::{len, + is_empty, cell}` and `range`. The tool reports 144 regions in 32 files; the floors in + `tools/realtime-policy/src/policy.rs` are 32 and 144 (departure 8's 31 and 143 are superseded). +6. **INFO.** The `tools/wasm-gate-corpus` header names the automation block at the end of the + layout. `quantum_invariance` runs once: its blocks hold no seek, so the mode loop added nothing. + +### Attempt 3 + +The adversarial verdict on attempt 2 (`40514b1fc`) failed it on one MEDIUM finding. + +1. **MEDIUM: a deferral fired inside a caller ramp reported after it.** `next_in_run` checked the + lane (`next < free_at()`) only for a fresh discontinuity. A deferral fired at its stored time + even when the caller had called `hold_until(later)` between blocks; with a jump ramp of 0, + `commit` then set the lane idle, so the next discontinuity also fired inside the caller's ramp. + That broke D4: a `no_restart` cell whose ramp is in flight takes the jump at the ramp's last + sample plus one. Fix: a deferral that is due (`is_deferred`) meets the same lane check as a + discontinuity; while the lane is busy it is deferred again to `free_at()` (the cursor advances + only for a discontinuity). The check also covers a deferral behind a forward span, which is due + at the run's first sample (attempt 2, finding 2). A deferral only moves forward, so the loop + ends. The bound is unchanged: at most one deferral is outstanding. + - Test `events::a_deferral_waits_for_a_caller_ramp_reported_after_it` (the verifier's case on + `deferral_table`): `hold_until(130)`, block at 64, `hold_until(200)`, block at 128 gives only + held grid events (attempt 2 gave a `Jump` at 131 and at 134), and the next block gives one + `Jump` at 201 to 3.0. Its second half carries the deferral behind a forward span to 500 with + the caller's ramp to 600: no jump before 601, one `Jump` at 601 to 3.0. Each half is red on + attempt 2's `events.rs` and green after the fix. + - `events::events_per_block_are_bounded` again proves the tables stress the bound: for + `q >= 64` the most events in one block exceeds `⌈q/64⌉`. + - D4's bound bullet and gate 3 now state the `no_restart` term. diff --git a/.github/ISSUE_SPECS/1638-apply-timed-operations-inside-a-block-on-the-fader-stage.md b/.github/ISSUE_SPECS/1638-apply-timed-operations-inside-a-block-on-the-fader-stage.md index 3eead45af..48bbc27f1 100644 --- a/.github/ISSUE_SPECS/1638-apply-timed-operations-inside-a-block-on-the-fader-stage.md +++ b/.github/ISSUE_SPECS/1638-apply-timed-operations-inside-a-block-on-the-fader-stage.md @@ -173,3 +173,112 @@ Checked against `12d4a4f9d`. - **Anchors moved.** `FaderRampStage`'s setters are now near `crates/builtins/src/lib.rs:3083-3140`, `BuiltinFaderBank` `:4314`, `FaderMuteRampBuiltins` `:4732`; re-read at filing. #1408 has landed. - **Realtime policy** as in #1637's amendment. + +## Decision record: #1638 (batch 13b) + +**Shipped** (`crates/builtins/src/lib.rs`). `FaderOperation` (D1), `FaderOperationError` +(`Lane`, `Gain`, `Duplicate`), `FaderOperationSink` (fixed size, on the render stack; one gain and +one mute slot per lane and channel; `push` refuses a padding lane, a gain that is not finite or is +negative, and a second operation of one kind at one offset, and a refusal queues nothing on either +channel), and the pull trait `FaderOperationSource { next_offset, apply }`. `FaderRampStage` gains +one flag per lane and channel, `mute_ramp` (D2): set by a mute change with a nonzero window, cleared +by a zero-window mute change, in the bookkeeping step that ends the countdown, by a live gain +retarget, and by `reset`; it is carried by `export_lane`/`import_lane` (a new private field of +`FaderChannelState`), so a plan swap during a mute ramp keeps the hold. `process_with` cuts the +block at each offset and runs `process_plane` over each piece, so a split moves no bit (D6); the +sink applies gains before mutes, left before right, whatever the push order (D4). Exposure (D5): +`BuiltinFaderBank::{process_with, apply_fader_operation, gain_held, ramp_remaining, +try_process_settled_with_matrix_and_operations}` and `FaderMuteRampBuiltins::{process_with, +apply_fader_operation, gain_held, ramp_remaining}`. No production path calls `process_with`. + +**Live rule (amendment, README F22).** `set_fader_gain` on a muted lane (settled or ramping toward +`0`) now only remembers the gain; on an unmuted lane, including one in an unmute ramp, it retargets +and clears `mute_ramp`. Timed `RetargetGain`/`SetGain` remember only while the gain is held (muted +or `mute_ramp`), so they also remember during an unmute ramp, as D2 says. + +**Departures.** +- Offsets: an offset at or past the block is not pulled and stays with the source; offsets must + rise strictly, and a repeated or lower offset stops pulling for the block (debug assertion), + which bounds the loop at `frames` pieces. +- D5's decline is a new method, `try_process_settled_with_matrix_and_operations`, which declines + when the source has an operation inside the block and otherwise forwards; the existing method and + its callers are unchanged. +- Gate 4 "every existing test passes unchanged" cannot hold with the amended live rule: in + `crates/builtins-compiler/src/lib.rs`, + `the_drain_applies_the_fader_before_the_mute_whatever_the_write_order` relied on a fader move + after a *mute* being observable; under F22 both orders are now alike. The test now starts the lane + muted and writes an *unmute*, where the order is still observable (fader-then-unmute ramps to the + new gain; unmute-then-fader cuts the unmute ramp short). Test-only edit; no wiring. +- `tools/realtime-policy/src/policy.rs`: `marked_regions_floor` 144 -> 147 (the sink, the bank's and + the scalar track's `process_with`); files stay 32. + +**Gates.** +1. `an_operation_inside_a_block_renders_the_split_blocks_bits` (Simd4, Simd8, scalar; q 128 at + o in {1, 63, 64, 127} and q 100 at o in {1, 50, 99}; six operations; settled, ramping, ramp + ending before o, muted, mute ramp, unmute ramp; one lane and two lanes at different offsets; + the next block compared too), `an_operation_at_offset_zero_is_block_entry_and_one_past_the_block_waits`. +2. `gain_moves_during_a_mute_ramp_are_remembered_and_never_restart_it` (timed `RetargetGain`, + `RememberGain` and the live setter, every 64 samples in a 480-sample mute ramp). +3. `at_one_offset_the_gain_applies_before_the_mute`. +4. Existing `fader_ramp.rs` tests unchanged and green; builtins-compiler as above. +5. `process_with_operations_never_allocates` (1,000 blocks per subject, thread counters: 0 and 0). +- D2 flag: `the_held_flag_follows_the_mute_ramp`; sink refusals: + `the_fader_operation_sink_refuses_without_queuing` (in-crate). +- Red on revert (copied aside, not stashed): live muted retarget restored -> gate 2 red; timed held + retarget -> gate 2 red; mutes applied before gains -> gate 3 red; operations one sample late -> + gate 1 red; flag not cleared at the countdown end -> gate 2 and the flag test red. + +**Successor obligation (#1647).** D2 holds a lane's gain during an *unmute* ramp too: a +`SetMute` with a nonzero window sets `mute_ramp`, and a timed `RetargetGain` or `SetGain` on a held +lane only remembers the gain (`FaderRampStage::timed_gain`, `crates/builtins/src/lib.rs`), while a +live gain change during an unmute ramp retargets. #1637 holds only `Grid` events: a `Jump` (on a +cell that is not `no_restart`) and a seek `Set` always emit and commit the cell's target with +`held = false`. So when #1647 converts a `Jump` or a `Set` that lands inside an unmute ramp to +`RetargetGain` or `SetGain`, the stage only remembers it; the unmute ramp settles at the +pre-unmute gain while `fader_gain` and the cell hold the new value, and on a flat curve the cell +emits nothing more (its target equals the curve and `held` is false), so the lane stays wrong with +no end. #1647 D1.2 (`hold_until`) covers only `Grid` events. #1647's amendment from this review +records the obligation and its gate. + +**Attempt 2 (review fold).** +- Docs: the successor obligation above; #1647 gains an amendment (the reissue at the end of a held + ramp, and its gate). `CellState::hold_until` only sets the in-flight completion, and `held` is set + only by a held `Grid` event whose value differs from the cell's target, so #1637 cannot re-emit a + committed `Jump` or `Set`; the amendment therefore makes #1647 reissue the cell's target itself. +- `FaderOperationSource` rustdoc states the contract: strictly rising offsets, and a source keeps + every operation it did not apply. `BuiltinProcessReport` gains `fader_operations_stopped` (`1` + when a `process_with` block stopped pulling on a repeated or lower offset, else `0`), set by both + `process_with` methods from a `bool` the stage now returns; the debug assertion stays. Not + tested: a debug test build fails the assertion first. No rendered bit moves. +- Test: `the_fused_settled_path_declines_only_an_operation_inside_the_block` + (`crates/builtins/tests/fader_ramp.rs`; Simd4, and Simd8 with AVX2): an operation inside the + block (declines, the block and every fader and matrix word unchanged, the source keeps the + operation, then `process_with` equals a twin), at `offset == frames` (forwards: equals a twin on + `try_process_settled_with_matrix`, the source keeps it), and no operation (forwards). Red, file + copied aside and restored with `cp`: `<=` for `<` (the block-end case), no decline (the inside + case), a decline that first runs the fused path (block untouched fails), a decline that applies + the operation (stage words fail). + +**Attempt 3 (record fold, coordinator).** The attempt-2 review measured that #1647's D1.6 as first +written was not quantum-invariant: refreshing `hold_until` at every block entry inside a held ramp +overwrote a cell `Jump`'s own ramp end. #1647's amendment now says when D1.2 reports the hold (once, +at the block entry that starts or restarts the ramp, and after an adoption that carries it), holds +the grid for the reissue's own ramp, composes the reissued gain as D1.4, and adds the moving-curve +cases to gate 8 (cases (c) and (e), whose samples `[E, E + J - 1]` equal one direct `RetargetGain` +at `E` over `J`). No code change. The counter `fader_operations_stopped` is to be summed by #1647 +when it wires `process_with` into production. + +**Attempt 3 review and rescope (coordinator).** The attempt-3 review confirmed the hold rule is +quantum-invariant and found two more design gaps in #1647's D1.6 (a grid event that is not held +inside the hold's last 64 samples; stopping the cell exactly at the ramp end). Three reviews found +this slice's code correct; every failure was in the successor's mechanism. The mechanism is +therefore #1647's design: #1647's amendment now states the requirement, the measured constraints and +gate 8, and #1648's amendment the carry. This slice's obligation is that record. + +**Gates 6 and 7 on the integrated tree (batch 13b, cddff37d9).** Gate 6: `audit capi` gives +`pcm_digest` `bf7dcedd1d92621f`, as on main; the browser fixtures' digests and exact rows are +unchanged (`check-browser-expected-resources`), and chromium 151, firefox 153 and webkit 26.5 +qualification pass with 0 render allocations. No fixture moves a fader on a muted lane, so the F22 +live rule moves no pinned digest; its behaviour is held by gate 2. Gate 7: the batch gate set +(workspace tests, clippy, `check-cross-targets.sh`, the policies) passes. The AArch64 legs +(`aarch64-debug`, Simd4 on NEON) run only in CI: this host has no AArch64 toolchain. diff --git a/.github/ISSUE_SPECS/1639-hold-the-automation-jump-lengths-in-a-cell.md b/.github/ISSUE_SPECS/1639-hold-the-automation-jump-lengths-in-a-cell.md index 777819c52..3d0fd0349 100644 --- a/.github/ISSUE_SPECS/1639-hold-the-automation-jump-lengths-in-a-cell.md +++ b/.github/ISSUE_SPECS/1639-hold-the-automation-jump-lengths-in-a-cell.md @@ -147,11 +147,13 @@ already in flight keeps the length it started with. ## Test value -- Gate 1: red if the graph reads the cell only at preparation, a block late, or reads an older - write. +- Gate 1: red if the graph reads the cell only at preparation, under no snapshot, or reads an + older write, or if the cell's allocations drift from its retained row. - Gate 2: red if the edit is `live` with no entry that reads a word (a needless write), `model_only` with one (the new lengths never reach render), or if equal sample counts still write. -- Gate 3: red if the commit does not write the cell, or writes it to a plan that is not the newest. +- Gate 3: red if the commit does not write the cell, or reports the path from anything but the + classifier's value; with a structural edit beside it (D4), red if the successor's cell is not + seeded from the committed model. - Gate 4: red if the block-entry read or the hand-off to the stages allocates on render. ## Dependencies @@ -180,3 +182,97 @@ Checked against `12d4a4f9d`. (amended) says. D1's row-to-word list is replaced by that function. - **Context.** #1054 and #1365 have landed; `LiveRamps` moved from host-core to `controls`. - **The browser** is the same `SessionState` (#1562a); D5 holds by construction. + +## Decision record: #1639 (batch 13b) + +**Shipped.** `crates/graph`: `GraphJumpLengths { fader_samples, mute_samples, pan_samples }` (the +three `LiveRamps` key values), one `LatestCell<3>` per plan with its dirty word in one `Arc`, the +boxed one reader in the executor (`JumpLengthReader`, not `Clone`, built only by +`graph::attach_jump_lengths`), the writer `GraphJumpLengthProducer::write(stamp, lengths)`, and +`jump_length_retained_bytes` / `jump_length_largest_allocation_bytes`. `render` reads the cell once +per block right after it takes the block's live snapshot, beside the source set's `begin_block` +(`read_counted`, so a replaced write joins `live_values_superseded`), and holds the three values for +the block. The test-support reader is `graph::test_only_block_jump_lengths()` (a thread-local the +block entry sets). `crates/host-core`: `session_jump_lengths(model)` (from +`LiveRamps::for_session`), preparation attaches and seeds the cell on every plan after bind and +charges it in the graph row, `HostLiveControlHandles::jump_lengths`; the classifier's +`LiveDelta::jump_lengths: Option` (read by `is_empty`) and +`automation_jump_row(target)`. `crates/control-plane`: the epoch's `StripLanes` holds the writer (preparation refuses a plan +without one, review fold 1); `commit_live_once` writes the value with the records, under the stamp, +before the revision's publication. D7's sentence is in `docs/SESSION_SCHEMA_V1.md`. + +**Departures and choices (code truth).** + +1. **Attach after bind, not in `PreparedGraphPlan`.** The cell is attached to the bound plan by + `graph::attach_jump_lengths(&mut PreparedRenderPlan, ...)` (a downcast, as the carry installers + do), so no builtins-compiler pass-through and no change to `bind` were needed. The seed is the + reader's initial held value, not a cell write: a fresh plan holds the prepared lengths at its + first block with no stamp at preparation. +2. **D3's row set** is `automation_jump_row`: a builtin, strip-own, route or VCA row whose live + motion is `Motion::Ramp(row)` (`controls::row_motion`), plus an effect's bypass row + (`BYPASS_CONTROL` -> `LiveRampRow::BypassCrossfade`, the `mute_ms` crossfade; its descriptor's + motion is `Fixed` because it takes no per-edit length). No hard-coded row-number list. Filters, + the delay, effect parameters and the (rebuild-only) link mode read no length. +3. **The count is charged on every plan that made its own.** The cell's reader holds a clone of + `live_values_superseded` in every plan, so the graph row now charges the preparation's own count + whenever the host passed none (before: only with strip controls). +4. **The fast classifier** (`classify_rows`) never sees a `control_smoothing` change + (`changed_rows` refuses session rows, so the full classifier runs); it leaves `jump_lengths` + `None`, with a comment. +5. **Known gap, by the spec's design:** a `control_smoothing` change committed while the session has + no reading entry is not written; an entry added later (model-only until #1648 classifies + automation edits) reads the cell's older lengths. The hazard section's rebuild rule (#1648 D1) + closes it for rendered rows. +6. **D3's field type.** D3 names `jump_lengths: Option<[u32; 3]>`; the shipped field is + `LiveDelta::jump_lengths: Option`, the same three lengths with a name for each + key (`fader_samples`, `mute_samples`, `pan_samples`), so no caller depends on the word order. + The cell still holds three `u32` words (`GraphJumpLengths::to_words`). + +**Re-pinned rows** (each "the jump-length cell, #1639 D1"; measured: the cell's two allocations are +168 bytes, a count 24 bytes, the revision gate 72 bytes; since the review fold gate 1 measures the +cell's bytes and its two allocations against `jump_length_retained_bytes()` with the thread +counters): + +| Test | Row | Before | After | +|---|---|---|---| +| `host-core` `tests/prepare.rs` `the_graph_row_counts_the_plans_gate_and_a_count_it_made_and_holds` | plain preparation (no strip controls), graph row less incremental | 72 (gate) | 264 (gate + cell + its own count) | +| same | strip controls, the preparation's own count | 96 | 264 | +| same | strip controls, the session's count | 72 | 240 | +| `host-core` `tests/live_routes.rs` `live_send_lanes_are_charged_against_the_graph_cap` | live total less idle total | route lanes + 24 (count) | route lanes (both plans hold the cell and their count); the test pins no total; live total 118304 at head (read from a failing run), 168 bytes (one cell) above the base it derives from | +| `host-web` `tests/retained_ceilings.rs` `the_graph_row_charges_the_plans_gate_and_not_the_sessions_superseded_count` | browser graph row less incremental | 72 | 240 | + +`capi` `model_only_edits_commit_without_a_plan_rebuild`: the `control_smoothing` edit now comes +before the automation upsert (on a session with a fader entry it is `live` by D3); not a byte row. + +**Gates.** + +| Gate | Test | Result | +|---|---|---| +| 1 Block-entry read | `graph` `tests/jump_lengths.rs` `the_block_entry_holds_the_newest_published_lengths` (prepared lengths at block 0; an unpublished write not held; held from the first block after publication; two writes give the last and count one superseded; a second attach refused; the attach leaves exactly `jump_length_retained_bytes()` live in 2 allocations, 0 frees). The probe is set right after the block's snapshot, so the test does not show where in the block the read is | pass | +| 2 Classifier | `host-core` `tests/live_delta.rs` `a_control_smoothing_change_writes_the_jump_lengths_only_for_automation_that_reads_them` (fader, mute, pan entries live; none, EQ parameter entry and 10.0 -> 10.001 ms empty; effect bypass entry live), `the_rows_that_read_a_jump_length_are_the_ramped_rows` | pass | +| 3 C ABI | `capi` `runtime::live_tests::a_control_smoothing_edit_on_an_automated_session_writes_the_jump_lengths_live` (SET_CONTROL of the three smoothing rows: `live`, same epoch, no candidate, next block holds 96/48/144; without automation `model_only`, prepared 960/480/960 held). Red with the commit's write removed. No candidate is pending, so it does not show which plan a write with a candidate reaches. D4: `a_successor_prepared_with_a_control_smoothing_edit_holds_the_new_lengths` (an added track and the three rows in one transaction: `rebuild`, one candidate; after adoption, a new epoch, the next block holds 96/48/144). Red with the seed made a constant 960/480/960: the successor held 960/480/960. | pass | +| 4 Realtime | gates 1 and 3 count 0 allocations and 0 frees in the block that applies the write; `audit capi`: every violation count 0 | pass | +| 5 Commands | `graph --features test-support`, `host-core` (all features), `capi`, `control-plane`, `host-web` suites; the `test-debug-a` workspace leg (2228 passed, 0 failed); `realtime_policy` (149 regions in 32 files; floor 147 -> 149 for this issue's two regions), graph, host-core and workspace policy scripts; `check-cross-targets.sh` (PASS, iOS memset only the known #1018 builtins/lane calls); workspace clippy `-D warnings`; `fmt --check` | pass | +| 6 No bit moves | `audit capi` `pcm_digest` `bf7dcedd1d92621f` at base and head. Browser legs (batch 13b integration, cddff37d9): `check-browser-expected-resources` digests and exact rows unchanged; chromium 151, firefox 153 and webkit 26.5 qualification pass | pass | + +**Review fold (batch 13b).** Five minor findings of the adversarial review, folded: + +1. **The invariant is typed.** The epoch's `StripLanes::jump_lengths` holds the writer, not an + `Option`: `prepare_runtime` refuses a handle set without it with the cell's own attach failure + (`host.jump_lengths.attach`, stage `Graph`), and `commit_live_once` lost its unreachable + `Internal` refusal. `HostLiveControlHandles::jump_lengths` stays an `Option` (handle sets built + without a preparation) and converts at the control-plane boundary. +2. **The cell's bytes are measured.** Gate 1 counts the attach with the thread counters (statics + warmed on a throwaway plan): requested less released bytes equal + `jump_length_retained_bytes()`, in 2 allocations and 0 frees. The counters carry no largest + allocation, so `jump_length_largest_allocation_bytes()` is not measured. +3. **Two claims removed.** Gate 1 no longer claims "a block late": its probe is set right after the + block's snapshot. Gate 3 no longer claims "a plan that is not the newest": that test has no + candidate. +4. **D4 has a C ABI test** (gate 3's row): `a_successor_prepared_with_a_control_smoothing_edit_holds_the_new_lengths`, + red with the seed made a constant. +5. **`session_jump_lengths`** moved out of the `use` block in `crates/host-core/src/prepare.rs`. + `prepare.rs` also moves the graph row's charge (the superseded count charged when the + preparation made its own), the largest-allocation row and a `HostLiveControlHandles` field, + past the authorized "the seed only": all three are needed for D1 and are covered by the root + ruling on byte rows. diff --git a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md index 45e640174..90f781bfd 100644 --- a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md @@ -65,9 +65,10 @@ admission path remains that could write a live record onto an automated lane. - **D1. Cells.** Host-core preparation compiles one cell per automated fader lane (target rack `builtins`, parameter 5; `both` gives two cells) with #1637's builder: - `a(n)` is the floored input arrival of the strip's `PostFader` node (#1285 D2); - - grid period 64, grid ramp 64, completion `End`; jump-length key `Word(Fader)` (#1637 D2): - each jump reads the `fader` word of #1639's plan cell for its block, so the program stores - no jump length; no `no_restart`; + - grid period 64, grid ramp 64, completion `End`; jump length `JumpLength::Block` (#1637 D2, + as shipped): the program stores no jump length; at render the caller resolves the fader + row's jump length for the block with `LiveRamps::for_row` from #1639's plan cell and passes + it to `CellEvents::new` as one `u32` (#1637's decision record); no `no_restart`; - **the offsets cell** (layout 4 of README finding F16). Preparation builds #1649's offsets cell for every automated fader lane, in every session, with or without VCAs: a #1312 latest-target cell of three words, the lane's offset sum `S` as one `f64` (two @@ -164,6 +165,10 @@ admission path remains that could write a live record onto an automated lane. - `crates/capi/src/runtime/tests.rs`, `hosts/host-web/src/tests.rs` (one new test, and the two oracles of D6 at `:13358-13371` and `:13697-13715`) - `crates/host-core/tests/vca.rs` (`reference_db`, `:268-279`, only) +- `crates/automation/src/events.rs` (one method that seeds a `CellState`'s target, and its test, + only if D2's event state needs a target at preparation: `CellState::new` leaves it empty and no + setter exists; #1648's batch 13b amendment authorizes the same method, and whichever lands first + adds it) - `crates/engine/src/realtime/latest_cell.rs` (a three-word cell form and its loom case, only if #1312's module has none) - `crates/session/src/vca.rs` (D6), `crates/session/src/lib.rs` (the `pub use` at `:31` only), @@ -271,8 +276,9 @@ admission path remains that could write a live record onto an automated lane. - #1645 *Validate builtin automation targets against their rows at preparation*. - #1637 *Compile stored automation into per-cell events in node time*. - #1638 *Apply timed operations inside a block on the strip fader stage*. -- #1639 *Hold the automation jump lengths in a plan cell*: the `fader` word the jump-length key - reads. +- #1639 *Hold the automation jump lengths in a plan cell*: the lengths from which a caller resolves + the fader row's jump length with `LiveRamps::for_row` (#1637 as shipped, departure 1 of its + decision record; no program holds a jump-length key). - *Session controlSmoothing: configurable ramp lengths for live mute, fader and pan changes* (#1054): the fader jump length. - *Keep every node's latency from dropping during playback* (#1285): `a(n)`. diff --git a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md index 1dab9f576..0a937f9fb 100644 --- a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md @@ -25,9 +25,11 @@ exactly as before. The fader row leaves the classifier's mask in #1648, in the s executor calls the source set at block entry (`crates/graph/src/lib.rs:3254-3256`), where #1641 advances the timeline. - **What #1646 leaves.** Each automated lane has a cell program (#1637) with its `a(n)`, its - grid and its jump-length key (the `fader` word of #1639's plan cell), an offsets cell (the lane's - VCA offset sum `S`, `+0.0` when no VCA reaches it; #1646 D1), and its event state in the fader bank stage. It starts at its prepared value, the - curve at node time `-a(n)`, which is the entry's first `start_value`. + grid and no jump length (#1637 as shipped, departure 1 of its decision record: a caller resolves + the fader row's length with `LiveRamps::for_row` from the three lengths #1639's plan cell holds), + an offsets cell (the lane's VCA offset sum `S`, `+0.0` when no VCA reaches it; #1646 D1), and + its event state in the fader bank stage. It starts at its prepared value, the curve at node time + `-a(n)`, which is the entry's first `start_value`. - **What #1639 leaves.** The graph reads the plan's jump-length cell once at block entry and holds its three words for the block; no stage reads them yet. - **The documents.** `docs/SESSION_SCHEMA_V1.md:220-225` says the table renders nothing; the @@ -44,7 +46,9 @@ exactly as before. The fader row leaves the classifier's mask in #1648, in the s `GraphPreparedBuiltinBankProcessor` (`process_timed`, which forwards to `process` by default), and the processor reads `timeline_block(s0 - a(n))` into #1637's `NodeSpan`. The same call passes the block's jump lengths, the three words #1639's read gives the graph for that - block, which each cell's events resolve its `Word(Fader)` key against (#1637 D4); + block; for each cell the processor resolves the fader row's jump length from them with + `LiveRamps::for_row` and passes it to `CellEvents::new` as one `u32` (#1637 D4, as shipped: + #1637 stores no jump key and writes no row rule); 2. tells each cell the end of a ramp in flight it did not start (a live mute ramp) through `hold_until`, from the stage's `remaining` (#1638 D2); 3. pulls each lane's events (#1637, `SeekMode::Exact`) and merges them by offset across lanes @@ -191,3 +195,59 @@ Checked against `12d4a4f9d`. (`validate.rs:868-870`). D4 rewrites those. - **Composition.** Each event's gain is #1646 D1's with `S` that includes the hand offset (#1650). - **Must-land group** is #1646, #1647, #1648, #1649 and #1650. + +## Amendment (controls coordinator, batch 13b, from #1638's reviews) + +Checked against `d5b9410df`. This is a requirement for this slice's brief, with the constraints +three #1638 reviews measured; the mechanism is this slice's design, reviewed with it. + +- **The gap (a requirement, D1.6).** #1638 D2 holds a lane's gain during an unmute ramp as well as + a mute ramp: while the stage reports `gain_held`, a timed `RetargetGain` or `SetGain` only + remembers the gain. #1637 commits a `Jump`, a seek `Set` and a grid event that is not held as the + cell's target with `held = false`, and on a curve that is flat afterwards it emits nothing more. + So any gain event the stage only remembers inside a held ramp leaves the lane at the pre-unmute + gain with no end. The requirement: when a held ramp ends with the gain free, the lane moves to the + automation value the cell then holds (composed as D1.4, with `S` and the #1650 hand offset), and + the result does not depend on the quantum (gate 5). Every remembered gain event counts, not only + `Jump` and `Set`: in every hold of length `R` the one grid point in its last 64 samples is not + held by #1637 (its own completion reaches the hold's end) and commits. +- **Constraints the design must meet (measured with the real `crates/automation`).** + 1. D1.2 reports the hold with `hold_until` once, at the block entry whose live drain starts or + restarts the held ramp, and again after an adoption that carries it (`CellState::reposition` + drops it). A refresh at later block entries overwrote a cell `Jump`'s own ramp end and made + `target()` at the ramp end -0.0616 dB at quantum 128 against -0.1955 dB at 100. + 2. The cell is stopped exactly at the ramp end `E`: the lane's `NodeSpan` ends at `E` and a second + span starts at `E + 1`, with any seek step kept in the piece that holds it; the per-lane merge + (D1.3) never pulls an event past `E` before the move at `E`. `CellEvents` borrows the + `CellState`, so `hold_until` cannot be called inside a span. A move built at the block end or + the next block entry instead gave -0.4273 dB at 128 against -0.4535 dB at 100. + 3. The move at `E` holds the grid for its own ramp (`hold_until(completion_of(End, E, J))`), as + after the cell's own `Jump`; otherwise a moving curve's grid events cut it into 64-sample + retargets. + 4. A ramp that ends muted keeps the obligation for the later unmute ramp's end. A live fader write + that clears the hold inside it discharges the obligation at that block entry. + 5. D3's "exactly, with no ramp" does not hold for a seek `Set` inside a held ramp: the stage only + remembers it, and it lands by the move at `E`. + 6. The stage's `fader_operations_stopped` counter (#1638) is summed into this slice's report. + 7. `E` is the first sample at which the stage reports `gain_held` false for the lane. The move at + `E` replaces any gain operation the cell gives at `E`: #1638's sink takes one gain operation per + lane, channel and offset and refuses a second (`FaderOperationError::Duplicate`). +- **Gate 8 (new).** At quantum 128, 100, 64 and 37, the same bits, and the lane never settles at the + pre-unmute gain, in these cases, with `J` the fader row's jump length: + - (a) a `Jump`, and (b) a seek `Set`, inside an unmute ramp on a curve flat after it: from + `E + J - 1` (from `E` when `control_smoothing` is explicit 0) the lane equals the gain composed + from the cell's `target()` exactly, so it settles at that value one fader jump length after `E`; + - (c) a `Jump` whose own ramp ends after `E`, with a block entry between the `Jump` and `E` at one + quantum only: samples `[E, E + J - 1]` equal the stage driven directly with one `RetargetGain` + at `E` over `J` (no grid event cuts the move's ramp); + - (d) a curve that goes flat inside the last 64 samples of the unmute ramp (no `Jump` or `Set`, + only grid events); + - (e) grid events that are not held lying between `E` and the next block entry at one quantum only + (a `Jump` whose ramp ends before `E`, or a seek `Set` on a moving curve): samples + `[E, E + J - 1]` equal the stage driven directly with one `RetargetGain` at `E` over `J`. + + Test value: red if a remembered event is never moved to at `E`, if the move follows a block entry + instead of `E`, if the lane does not equal the composed `target()` from `E + J - 1` (or from `E` + with `control_smoothing` 0), if D1.2 refreshes `hold_until` inside the hold, or if a grid event + cuts the move's ramp in `[E, E + J - 1]`. +- **#1648** carries the obligation across a swap (see its amendment). diff --git a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md index 2f93e02f6..c1e3187b4 100644 --- a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md +++ b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md @@ -250,3 +250,31 @@ Checked against `12d4a4f9d`. `crates/capi/src/runtime/live_tests.rs`. Realtime policy as in #1637's amendment. - **Dependencies.** #1277, #1313, #1314 and #1335 have landed; add #1562b. Must-land group: #1646, #1647, #1648, #1649, #1650. + +## Amendment (controls coordinator, batch 13b, from #1638's reviews) + +- **D2 also carries #1647 D1.6's state.** The per-lane, per-channel obligation to move to the + automation value at a held ramp's end carries with the cell's event state, and the successor + re-reports a carried held ramp to the cell with `hold_until` at adoption (`CellState::reposition` + drops it). +- **Release cells too.** A release cell (D2, "A cell lost") takes the same carried obligation and + the same `hold_until` report at adoption. Its jump to the static value is a gain event: inside a + held ramp the stage only remembers it, so the obligation must move the lane to the static value at + the ramp's end, as #1647 D1.6 does for a carried cell. +- **Seeding a cell's target.** D2 starts a gained cell from the carried stage target and a release + cell from the carried state, and #1646 D2 starts each cell at its prepared value. #1637's + `CellState` (`crates/automation/src/events.rs:106-155`) has no way to set its current target from + outside: `new` leaves it empty and `reposition` keeps it. Authorized paths gain + `crates/automation/src/events.rs` (one seeding method on `CellState` that sets the current + target, and its test), because no other path can give a cell the target these rules start it + from. + +## Amendment (controls coordinator, batch 13b, from #1639's review; root ruling 2026-10-15) + +- **Jump lengths follow a rebuild.** #1648 D1 makes every automation edit on a row that renders + a rebuild, which seeds the plan's jump-length cell from the committed model (#1639 D4). A row that + does not render yet reads no length, and its rendering slice (#1651, #1657, #1658) makes its + edits rebuilds the same way, so the cell is right from the first block any row reads it. +- **Gate.** Add to the objective gates: `control_smoothing` changed on a session with no reading + automation, then a fader entry added: from the adoption block, the block holds the new lengths + (`graph::test_only_block_jump_lengths`). diff --git a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md index 4a4810618..4b987ec68 100644 --- a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md +++ b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md @@ -189,3 +189,26 @@ Checked against `12d4a4f9d`. - **Automated VCA mutes** (root's F20 ruling) compose with this slice's `curve || terms` in #1655: the stage mute of a member lane gains `|| vca_curve(τ)` for each reaching VCA whose mute is automated. This slice's `terms` word keeps the static VCA mutes. + +## Amendment (controls coordinator, batch 13b, root ruling) + +- **A `SetMute` that changes nothing does nothing** (constraint). Today #1638's `SetMute { muted, + samples }` reaches `mute_channel` (`crates/builtins/src/lib.rs`, through `apply_operation`; the + live `set_mute` calls the same function), which always writes `muted`, retargets the lane to `0.0` + (muted) or to the remembered `fader_gain` (unmuted) over `samples`, and sets `mute_ramp` when + `samples` is not 0, whether or not the lane's mute state changed. So a `SetMute` that repeats the + lane's state restarts a fader ramp in flight over the mute length (its shape changes), and its + `mute_ramp` makes `gain_held` true for that window, so the fader events inside it are only + remembered. The rule: a `SetMute` whose `muted` equals the lane's and channel's current mute state + leaves the stage as it was (no retarget, `mute_ramp` and the ramp's remaining frames unchanged). + D1's "only a change emits" still holds; this rule makes the stage itself safe against a repeated + `SetMute`, whatever composed it. The change + is in `crates/builtins/src/lib.rs`, which this slice's authorized paths already allow; its test + goes in `crates/builtins/tests/fader_ramp.rs` (authorized). +- **Gate 2, new case.** On the stage driven directly with #1638's operations: (a) an unmuted lane + with a fader `RetargetGain` in flight takes `SetMute { muted: false, samples: 240 }` at an offset + inside the ramp, then a `RetargetGain` 10 samples later; (b) a lane in a mute ramp toward `0.0` + takes `SetMute { muted: true, samples: 240 }` inside it. Each block is bit-identical to a twin + that never received the repeated `SetMute`, and in (a) `gain_held` stays false. Red if a repeated + `SetMute` retargets the lane or starts a mute ramp. Gate 7 holds that the change moves no bit of a + session with no stored automation. diff --git a/.github/ISSUE_SPECS/1676-cap-the-observation-bindings-by-what-one-ended-event-holds.md b/.github/ISSUE_SPECS/1676-cap-the-observation-bindings-by-what-one-ended-event-holds.md index 774947f00..ca384cd2c 100644 --- a/.github/ISSUE_SPECS/1676-cap-the-observation-bindings-by-what-one-ended-event-holds.md +++ b/.github/ISSUE_SPECS/1676-cap-the-observation-bindings-by-what-one-ended-event-holds.md @@ -60,3 +60,182 @@ frame size. ## Size Half a day. + +## Decision record: #1676 (batch 13b) + +**What shipped.** + +- **D1, the cap.** `control_plane::compile::observation_binding_cap(frame_bytes)` measures, with the + real codec, the `OBSERVATION_ENDED` frame of one key and what a second adds, with both IDs at + `STABLE_ID_MAXIMUM_BYTES` (127), and returns how many keys one control frame holds (1 KiB frame: + 3, 4 KiB: 12, the browser's 16 KiB: 51). `SessionState` keeps it (`observation_cap()`), with an + exact `observation_armed()` count. Each observation slot now carries `armed` (the table's state + with every push folded in) beside its pending push, so a subscription is judged without a key + lookup or an allocation inside `process()`. A rebuild rebuilds the slots from the bindings it + keeps (`observation_slots(producers, bindings)`), so the count survives a swap and drops the + taps a transaction ended. `SessionState::admit_observation_changes(changes)` admits a batch by + the taps it leaves armed (arms of unarmed taps add, disarms of armed taps remove, a window change + adds nothing) and returns `ObservationLimit { index, cap }` for the first arm that crosses. It + moves nothing, so a refusal is whole and precedes every ack. +- **Browser mapping** (attempt 1; superseded by attempt 2 below: reason 15 `observationLimit`, + `RESULT_UNSUPPORTED`). `admit_commands` calls it after pass one and before the room pass; a + refusal is `RESULT_BACKPRESSURE` / `COMMAND_REASON_BACKPRESSURE` at the record that crossed it + (doc comments on the Rust constant and both `.d.ts` copies say so). Nothing else in the adapter. +- **D2, the reserve.** The ended-event heap per cell is now `min(slots, cap)` keys times + (`size_of::()` + two maximum IDs), not the frame size. The binding table and + its transient copies use the same `min(slots, cap)`. +- **Charge by high water.** The slots in that charge are the most any committed plan of the session + has had (`observation_slots_high_water`, passed to `prepare_runtime`), not only the plan being + prepared. A cell holds an event until its frame is delivered, so an event a larger earlier plan + emitted can outlive that plan; charging the shrunk plan by its own slots would let a cell hold + more than its row says. The first plan charges its own slots, so the reference figures are exact. +- **Docs.** `docs/CONTROL_PROTOCOL_SEMANTICS.md` (observation paragraph) states the cap and its + figures. + +**Departures.** + +1. **Refusal code** (attempt 1; superseded by attempt 2 below: reason 15 `observationLimit`, + `RESULT_UNSUPPORTED`). The browser's `COMMAND_REASON_*` vocabulary is gate-pinned as contiguous from + 0 across six files, and 12-14 are retired and never reused; a new reason would need a vocabulary + decision. The typed refusal is `ObservationLimit` in the core; the browser maps it to the + existing backpressure reason (unsubscribe a tap, then retry succeeds). No `crates/protocol` + change and no re-pin. A dedicated reason is successor work if the owner wants one. +2. **Parity row.** Only the browser subscribes today (the C ABI's `Observe` edit is #1584 D1-D5 and + `observation_taps` stays 0 there), so there is no C ABI path to put in + `hosts/host-web/tests/control_parity.rs`. Both hosts share the one core cap, the one reserve + and the one slot state; the C ABI edit must call `admit_observation_changes` (or its own edit + check against `observation_cap()`). (Since attempt 2 that function is private; the C ABI edit + calls `admit_and_push_observation_batch`, as #1584's batch 13b amendment says.) +3. (Attempt 1; superseded by attempt 2 below, which removes the uncapped public push.) + `push_effect_observe` does not itself refuse past the cap: a batch can push its arms before its + disarms, so the cap is judged on the batch, by `admit_observation_changes`, which every caller + must call first. The over-frame refusal in the commit stays as a defence; its existing test now + says it pushes past the cap on purpose. + +**Gates and tests.** + +- Gate 1: `control-plane/tests/observation_bindings.rs` + `a_subscription_past_the_cap_is_refused_and_the_removal_of_all_still_commits` (small frame; the + cap+1 arm is refused with index and cap, a disarm in the batch makes room, re-arming an armed tap + adds nothing, admission moves nothing, and removing the strip commits with one event holding all + `cap` keys), `the_cap_is_what_one_frame_carries_with_the_longest_ids` (cap longest keys fit the + frame, one more does not, at 1/4/16 KiB), `the_armed_count_follows_the_bindings_across_a_rebuild`; + browser: `host-web` `a_subscription_past_the_cap_is_refused_and_an_unsubscribe_makes_room`. +- Gate 2: `the_reserve_follows_the_slots_and_a_shrunk_plan_keeps_the_larger_reserve` (the reserve + at 4 KiB equals the reserve at 16 KiB for a few slots; a plan that shrank from 14 to 8 slots + charges at least the larger reserve). `capi_retained_bytes` on the reference session measured + 165,782 (x86-64, 4 KiB frame; the row does not depend on the lane width; it was 186,614 after the + 12a fold and 165,758 before it), so the ceiling is re-pinned to 165,782 plus 10 %, rounded up to + 64: 182,400, at both widths (was 205,312). The four-lane figure is not measured here (no AArch64). +- Gate 3: `capi` `resource_lifecycle` (charge equals retained, exact) and the full crate suites + pass; clippy `-D warnings` on control-plane, controls, capi and host-web, all targets and + features. + +**Successor candidates.** A dedicated browser command reason for the cap (needs a vocabulary +decision; attempt 1, superseded: attempt 2 adds reason 15 `observationLimit`). The C ABI `Observe` edit must admit against `observation_cap()`. 51 simultaneous taps at +the browser frame is the product limit; a larger frame raises it linearly. + +### Attempt 2 (batch 13b) + +**Root ruling (2026-10-15).** A cap refusal is never backpressure. The browser vocabulary gains +command reason 15, `observationLimit`; reasons 12-14 stay retired and are never reallocated, and the +declared values are exactly `range(0, max + 1)` minus `{12, 13, 14}`. This supersedes attempt 1's +departures 1 (the cap reported as `backpressure`) and 3 (an uncapped public push). + +- **The reason, in every spelling.** `COMMAND_REASON_OBSERVATION_LIMIT = 15` in + `hosts/host-web/src/lib.rs` (result `RESULT_UNSUPPORTED`, the same result as + `observationUnbound`: the session's configuration cannot take one more tap, and only an + unsubscribe changes that); the host JS table (`null` at 12-14; `validCommandReason` refuses a + `null` entry); the `.d.ts` `MisoCommandReason.ObservationLimit = 15` and its SDK mirror; the + metadata generator and the ABI-layout generator rows (`tools/parameter-metadata`); the schema + gate's own list; the ABI-layout gate's list (now explicit `(value, name)` pairs). The SDK assets + and `sdk/src/generated/{abi,catalog}.ts` were regenerated with `node codegen/assets.mjs` and + `node codegen/generate.mjs`. The C ABI header declares no command reasons, so nothing changes + there. No schema hash or ABI digest is pinned for these files, so nothing was re-pinned + (`abiVersion` stays `65536`: a new reason value is additive). +- **Gates for the gap.** `check-command-reason-vocabulary.py`, `check-parameter-metadata-v1.py` + and `check-abi-layout-v1.py` require "dense except the retired set". The vocabulary gate reads + the JS table's `null` holes (a hole at a non-retired value is red), takes the generator's values + from the Rust constants, maps the schema gate's hole-free list onto the non-retired values, and + requires the schema gate's `RETIRED_COMMAND_REASONS` to be its own. New red mutations: the JS + bound accepting a retired `null`; the JS table losing a hole or holing a live value; + `observationLimit` moved to 12 or to 16 in the Rust constants; dropped from the `.d.ts`, the + generator or the schema list; on 12 in the `.d.ts`; the schema retired set losing 14; plus eight + direct cases of the dense rule (31 mutations in all). The metadata gate's self-test gains four + (reallocated 12, gap at 16, missing 15, missing 11) and the ABI-layout gate's two. The on-disk + drift mutation in `test-web-audioworklet.sh` now adds a reason at 16. The hermetic worklet test + (`scripts/test-web-audioworklet.mjs`) settles a reason-15 acknowledgement as a typed refusal with + result 7 and a healthy host afterwards. +- **SDK.** `#submit` already resent only on `backpressure`; a comment now names + `observationLimit` among the reasons thrown at once, and `commandFailure` names the reason and the + record in the message, with a hint for the cap ("unsubscribe another tap first"). + +**Review findings.** + +1. *Short IDs let the removal half pass with no cap.* The core test + `a_subscription_past_the_cap_is_refused_and_the_removal_of_all_still_commits` now renames + `comp1` to a 127-byte strip ID and adds 127-byte insert IDs at the 1 KiB frame, so the `cap` + keys of the removal fill the frame. Its second half pushes `cap + 1` taps past admission (the + test-support hook `test_push_observe_unadmitted`) and requires the same removal to be refused + by the over-frame defence (`ResourceFault::Limit`). **Red without the fix:** raising the cap by + one (`observation_binding_cap(..)? + 1` in `compile.rs`) turns it red at the removal (`cap + 1` + longest keys are refused); run and observed red, then restored. +2. *`push_effect_observe` was public and uncapped.* It is now `pub(crate)`. The one public entry + is `SessionState::admit_and_push_observation_batch(changes)` (`ObservationChange` items): it + runs the cap check, then the queue-room check (moved from the browser into the core, since the + queues are the core's), then queues every change, and returns `ObservationRefusal::{Limit, + Full, NoProducer}` with nothing written on a refusal. A `debug_assert!` checks armed <= cap + after the push, in the core and again in the browser after its admission. The browser's + `admit_commands` calls it once after pass one; the room-refusal order is unchanged (cap, then + room, both before any write) and its own host-side state (armed masks, arm samples, in-flight + counts) is written only after the core admits. One departure in order: the browser's in-flight + counter overflow check (unreachable: the queue room bounds it far below `u32::MAX`) now runs + before the core call, because nothing may be refused after a push. No token was needed. Tests + that push without admission use the test-support hook only. +3. *A refusal at record index > 0.* The host-web test submits `[unsubscribe 0, subscribe cap, + subscribe cap + 1]` with the cap full and requires `observationLimit` at rejected index 2, with + nothing kept. The core test also requires `ObservationLimit { index: 1 }` for a batch whose + first change re-arms an armed tap. **Red without the fix:** mapping the crossing change to index + 0 in `lib.rs` turns the host-web test red (`rejected_index` 0, expected 2); run and observed. +4. *The host-web test expected backpressure.* `a_subscription_past_the_cap_is_refused_and_an_unsubscribe_makes_room` + now expects `(RESULT_UNSUPPORTED, COMMAND_REASON_OBSERVATION_LIMIT, 0, 0)`. **Red without the + fix:** mapping the cap back to `COMMAND_REASON_BACKPRESSURE` turns it red (6, 8 against 7, 15); + run and observed. +5. *The SDK must not resend the cap.* New eval in `sdk/test/observation-subscription-evals.mjs`: + a scripted host refuses one subscription with result 7, reason 15; the call rejects at once with + a `MisoEngineError` whose message names `observationLimit at record 0`, and exactly one command + was sent although a render happens 30 ms later. **Red without the fix:** letting `#submit` retry + on `observationLimit` turns it red; run and observed. + +**Docs.** `docs/CONTROL_PROTOCOL_SEMANTICS.md` (observation paragraph) now says the browser reports +the cap as `RESULT_UNSUPPORTED` with reason `observationLimit` and that the core admits and queues a +batch in one call. `hosts/host-web/MUTATIONS.md` lists the new mutations. + +**Review fold (batch 13b).** Three MINOR findings on attempt 2 (`fba4eeab8`, PASS). + +1. *The core only documented "each tap named once".* `admit_and_push_observation_batch` now + refuses a second naming of a (producer position, tap index) in one batch with the new + `ObservationRefusal::Duplicate { index }`, at the second naming and before anything is pushed; + the precondition sentence is gone from the doc. The browser maps it to `MALFORMED` at that + record, the answer its own pass one already gives a duplicate, so browser behaviour does not + change. Test: `a_batch_that_names_one_tap_twice_is_refused_at_the_second_naming` + (`crates/control-plane/tests/observation_bindings.rs`), at the 1 KiB frame with the cap full: + `[disarm A, arm A, arm B]` and `[arm B, arm B]` are each `Duplicate { index: 1 }`, move nothing + and queue nothing, and a later batch naming each tap once is admitted. Every shipped effect + declares one tap, so its queue has one slot and the cap+1 outcome itself is not reachable from a + test: the batch is reached through the public core call, and the room check masks the + undercount. **Red without the fix:** with the check disabled, the first batch is `Full + { index: 0 }` and the second `Limit { index: 0 }`; both run and observed red, then restored. +2. *The room check was O(n^2).* Admission is now three linear passes over the batch with no + allocation: pass 1 marks each change's slot and counts each producer's records, pass 2 is the + cap, pass 3 the room, reading one count per producer. The scratch is a per-slot `mark` in + `ObservationSlot` and a per-producer `ObservationBatchCount { mark, needed }` box built with the + plan's slots in `observation_slots` (and charged as a retained row beside the offsets); a batch + stamp makes stale marks invalid without clearing, and on the stamp's wrap every mark is reset + (bounded, allocation-free). Refusal order: **duplicate** (the first second-naming in batch + order, over the whole batch), then **cap** (the arm that crosses), then **no producer or full** + (one check, the first offending record in order). A duplicate therefore wins over a cap or room + refusal at an earlier record, because the cap count is only sound once duplicates are out. +3. *`hosts/host-web/MUTATIONS.md` was stale.* The vocabulary self-test row now says thirty-one + in-memory mutations (the script reports 31 plus 8 dense-rule cases), and the metadata self-test + row says reason 11 is renumbered to 16, and lists #1676's four new mutations. diff --git a/.github/ISSUE_SPECS/1677-address-observation-records-by-stable-key-in-the-core.md b/.github/ISSUE_SPECS/1677-address-observation-records-by-stable-key-in-the-core.md index 1bf1bbaf4..e116d4a3d 100644 --- a/.github/ISSUE_SPECS/1677-address-observation-records-by-stable-key-in-the-core.md +++ b/.github/ISSUE_SPECS/1677-address-observation-records-by-stable-key-in-the-core.md @@ -39,3 +39,79 @@ another effect's tap. ## Size One day. + +## Decision record: observation records addressed by stable key (batch 13b, lane W) + +**What shipped.** A browser observation record (kinds 7 and 8) names its tap by stable key, the +same key `OBSERVATION_ENDED` uses: the strip ID, the rack (`1` inserts, `3` console), the console +slot's or insert's ID and the effect-local tap ID. It carries no index of any plan. + +- **Record.** 272 bytes, little endian: `kind` u8 @0, `rack` u8 @1, `stripIdBytes` u8 @2, + `effectIdBytes` u8 @3, `tapId` u32 @4, `windowBlocks` u32 @8, a required-zero u32 @12, `stripId` + 128 bytes @16, `effectId` 128 bytes @144, each ID zero padded (an ID is 1 to 127 bytes, so the + last byte of a field is always zero). `COMMAND_RECORD_BYTES` 48 to 272, new constant + `RECORD_ID_FIELD_BYTES` 128; `ABI_VERSION` did not move (prelaunch, V1). The command staging grows + from 12,288 to 69,632 bytes, allocated at boot as before (`MAXIMUM_COMMAND_RECORDS` is 256). +- **Core.** One function in control-plane resolves the key: + `SessionState::resolve_observation_key(&ObservationKeyRef { strip_id, rack, effect_id, tap_id })` + returns `ResolvedObservationTap { producer, tap_index }` or an `ObservationKeyError` + (`UnknownStrip`, `UnknownEffect`, `UnknownTap`, `ComputedTap`), writing nothing. It resolves on the + newest committed plan: the strip in the committed model (so a strip with no such effect is an + unknown effect, not an unknown strip), the effect among `effect_producers()` (the pending + candidate's while a rebuild waits for its block), the tap in the descriptor's menu + (`resolve_observation_tap`, also exported). Byte compares, no allocation, so it runs inside + `process()`'s message handler; the C ABI's future observe edit (#1584 D1-D5) calls the same + function. host-web only decodes the staged bytes into the borrowed key (a rack code outside 1 and + 3 is `unknownRack` there) and maps the refusal to the wire's reason (`unknownTrack`, + `unknownEffect`, `unknownTap`, `unsupportedKind` for a computed tap), all in pass one before the + room pass and any write, so nothing is queued and no ack precedes a drop. It then reaches its + per-slot tables from the producer position through the new `PlanShape::producer_slots` (the + inverse of `EffectInstance::producer`, built with it) and refuses `observationUnbound` for an + effect with no reader. A malformed record (a nonzero reserved word, a length that fills its field, + a byte after the end of an ID) is `malformed`. The codes keep their numbers and names. The + selected read still resolves its IDs on the rendering plan through + `ReadyOwnership::effect_by_id`, which is now only the read's. +- **Edits to control-plane.** `control.rs` (the function, its key and error types and + `resolve_observation_tap`) and its re-exports in `lib.rs`; no edit to `prepare.rs`, host-core or + capi. +- **Worklet.** `command()` takes `{ kind, rack, stripId, effectId, tapId, windowBlocks }`; + `observe()` takes `{ stripId, rack, effectId, tapId, windowBlocks, armed }` and its binding map is + `{ stripId, rack, effectId, tapId, windowBlocks }`, sorted by those. `frameSlot`, `trackIndex` and + `effectIndex` leave the subscription map (a strip's gain-reduction slot is its position in the + control list's strip order). An ID over 127 UTF-8 bytes or a non-string rejects locally. The + worklet's `receiveCommand` is unchanged apart from the record size; `process()` is untouched. +- **SDK (thin client).** `LaneEdit` is `{ kind, rack, stripId, effectId, tapId, windowBlocks }`; + `observationEdit()` builds it from a key; `effect(...).observe()` needs a stable ID + (`console(slot, ...)`, `insert(id, ...)`, or an index resolved by `withSession`). The managed + owner builds each record from the selection and sends it. Deleted: `#remap`, `OBSERVED_STRIP`, the + strip-index re-addressing in `submitObservation`, `#settle`'s epoch wait and + `MAXIMUM_STRUCTURE_WAITS`, `submitManagedAt`, `structureBehindError` and + `sdk.observation.structure_behind`, `syncStructure`, `ObservationMap.structureEpoch`, the + `stripOrder` of `LiveControlTransport`, `LiveControlEdits.update`, and the strip and effect index + plumbing of the generated builders (`StripAddressing`, `EffectInstance`). +- **C ABI.** It does not admit observation records (no subscribe export), so there is no parity row. + +**Gates and tests.** + +1. A subscribe between a rebuild's commit and its adoption arms the named tap, never another: + control-plane `a_stable_key_resolves_on_the_newest_plan_and_an_unknown_key_is_refused` (the key + follows the effect when a committed removal moves its producer, and an unknown strip, insert, + rack, tap or reserved tap zero is refused with its own error); host-web `structural_apply::the_catalogs_describe_the_successor_from_the_adoption_block_on` (the + record for `track`'s compressor, which is index 1 in the successor, arms exactly it and not + `a-track`'s) and `a_tap_command_admitted_at_sample_zero_survives_the_hand_over`; SDK + `structure-evals.mjs` "#1677 -- an observation record names its tap by stable key" (subscribe, + close and update in the window, headless and through the browser transport over the real core, + on `swap_t3_multi_and_limiter` where two effects with the same tap swap indices); + `measurement-evals.mjs` (the browser engine sends the key with a stale map, without a wait). +2. An unknown key is refused with its typed code and nothing changes: control-plane (above); host-web + `observation_misuse_is_typed_and_all_or_nothing` (malformed forms, unknown rack, effect, strip, + tap), the in-window refusals above, and `structure-evals.mjs` (a valid record in front of an + unknown strip, effect, tap or rack is not armed either; a tap the replacement no longer has is + refused against the newest plan). +3. SDK headless and browser evals (554 pass), `test-web-audioworklet.mjs`, `direct-oracle.mjs`, + the writer evals for the record encoding (`writer-evals.mjs`), the layout checks + (`check-abi-layout-v1.py` and its self-test, `check-parameter-metadata-v1.py`, + `check-command-kind-vocabulary.py`, `check-command-reason-vocabulary.py`). + +**Departures.** The test fake of `test-web-audioworklet.mjs` now has three pages of memory (the +command staging no longer fits one). The `observe()` map lost `frameSlot` (above). diff --git a/.github/ISSUE_SPECS/1678-refuse-an-effect-row-edit-whose-effect-changed-in-the-core.md b/.github/ISSUE_SPECS/1678-refuse-an-effect-row-edit-whose-effect-changed-in-the-core.md index 5573131d9..c022b11fc 100644 --- a/.github/ISSUE_SPECS/1678-refuse-an-effect-row-edit-whose-effect-changed-in-the-core.md +++ b/.github/ISSUE_SPECS/1678-refuse-an-effect-row-edit-whose-effect-changed-in-the-core.md @@ -37,3 +37,22 @@ before each named-row edit. ## Size Half a day to a day. + +## Decision record: #1678 (batch 13b) + +**What shipped.** + +- Wire: `SET_CONTROL`'s address is its own message, `SetControlAddress` (`schema::set_control_address`): the list's six address fields and an optional `7:expected-effect UTF8`. The control list's address message and a stored automation target keep `ControlAddress` (the session JSON keys pair with it, so field 7 would break that pairing). Encoder and decoder: `write_set_control_edit_address` / `decode_set_control_edit_address` in `message_wire`; the field is refused as `INVALID_FIELD` unless the section is `inserts` or `console` and the value is a stable ID. +- Model: `SessionEdit::SetControl` gains `expected_effect: Option`; `SessionEdit::set_control` keeps its signature (no expected effect) and `with_expected_effect` adds one. +- Core check: `controls::check_expected_effect` (in `effect_rows.rs`) returns the new `ControlError::EffectChanged`, code `control.effect.changed`. It runs in `apply_session_edit` (full path, against the candidate at the edit's place in the transaction, so a transaction that replaces the effect first is judged by the new one) and in `prepare_in_place` against the committed model; the in-place path declines on a mismatch and the full path answers with the typed refusal. An address that names no insert or slot is still `control.address.unknown`. Both hosts reach this through `SessionStore`, so there is no host code. +- SDK: the builders send the expected effect (typed effect of an effect builder, the effect of the section for a named row, `expectedEffect` of a numeric `ControlWireAddress`); `effectRowResolver`, its provenance map and the control queue's `prepare` hook (its only user) are deleted, so a named-row edit no longer probes the control list. A named row follows no header any more: an edit built from the old view is sent for the old effect and the core refuses it. +- Re-pins: `COMPLETE_SCHEMA_HASH` `0xcc9a_7bcc_502c_4936` to `0x63ff_2e60_976e_0e21` (the corpus transaction appends a `SetControl` on an insert row with expected effect `miso.parametric-eq`: 36 edits; the controller test's edit-limit boundary is now 35); parity self-test strings, `fuzz/corpus/complete-schema-manifest.md`, `docs/CONTROL_PROTOCOL_CONFORMANCE.md` history lines; `crates/protocol/tests/fixtures/set-control-frames.json` blessed (new `expectedEffect` key and an `expected_effect` command). `docs/CONTROL_PROTOCOL_REGISTRY.md` documents field 7 and the code. No C ABI vector carries the field. No rendered bit moved (audit capi `pcm_digest` `bf7dcedd1d92621f`). +- Integrated in batch 13b: one re-pin from `0xcc9a_7bcc_502c_4936` to `0x0c71_96f8_6abe_6d57` for #1635 and #1678 together. + +**Departure.** The spec says the codec "gains an optional field"; the field is on a new message that shares fields 1 to 6 with the list's address, not on the shared message (reason above). + +**Gates -> tests.** + +1. `hosts/host-web/tests/control_parity.rs::a_stale_effect_row_edit_is_refused_alike_on_both_hosts` (rebuild replaces `t2`'s transient shaper by a soft clip; the stale edit gets one verdict, one reply, `control.effect.changed`, unchanged control rows and document, equal blocks; the same edit without the expected effect, and with the new one, commit live on both hosts, at four rates). Store level, both preparation paths: `crates/protocol/tests/expected_effect.rs` (red with the check removed). Wire: `session_wire::tests::set_control_expected_effect_is_held_to_its_section`, `crates/protocol/tests/set_control_frames.rs` fixture, `sdk/test/control-frame-evals.mjs`. +2. `sdk/test/structure-evals.mjs` ("#1678", headless and browser transport), `sdk/test/control-builders-evals.mjs` (every effect row carries `expectedEffect`). +3. Run: protocol (`test-support`), control-plane (`test-support`), controls, capi, conformance, host-core, host-web (`host-web/test-support`), parameter-metadata; clippy `-D warnings` on host-core (no features, default, all features) and the touched crates; fmt; `check-protocol-wasm-parity.sh` and `--self-test`; command kind and reason vocabulary checks; fuzz `cargo check`; protocol allocation audit; SDK types, generated, headless (557 evals), package; realtime-policy; bench-policy; chromium qualification. diff --git a/.github/ISSUE_SPECS/1685-admit-observation-bindings-by-the-exact-encoded-bytes-of-the-ended-event.md b/.github/ISSUE_SPECS/1685-admit-observation-bindings-by-the-exact-encoded-bytes-of-the-ended-event.md new file mode 100644 index 000000000..2c56182e8 --- /dev/null +++ b/.github/ISSUE_SPECS/1685-admit-observation-bindings-by-the-exact-encoded-bytes-of-the-ended-event.md @@ -0,0 +1,100 @@ +# Admit observation bindings by the exact encoded bytes of the ended event + +Successor of #1676 (cap the observation bindings by what one ended event holds) and #1584 D6a +(root ruling A: one `OBSERVATION_ENDED` per commit). Code anchors verified on the batch 13b fold +branch `codex/controls-b13b-fold`. Issue number: assigned at filing (written here as #1685). +Batch 14, the first K (core) slice. Feature sprint mode (owner, 2026-10-13): one implementer, +normal tests, a batch review. + +## Product outcome + +A mixer can watch a meter on every insert of a large session. The host refuses a subscription only +when the one `OBSERVATION_ENDED` event that a commit could emit would not fit in one control frame, +measured with the real IDs of the armed taps, not with the longest IDs the grammar allows. + +## Context + +- **The cap today.** `observation_binding_cap(frame_bytes)` (`crates/control-plane/src/compile.rs`, + near `:483-523`) encodes an `ObservationEnded` event with keys whose strip ID and effect ID are + both `session::STABLE_ID_MAXIMUM_BYTES` (127) bytes, and returns how many such keys one frame + holds: 3 at 1 KiB, 12 at 4 KiB, 51 at the browser's 16 KiB (`CONTROL_FRAME_BYTES`, + `hosts/host-web/src/lib.rs`). `SessionState::observation_cap()` (`crates/control-plane/src/control.rs`, + near `:2677-2683`) keeps that count, and `admit_and_push_observation_batch` (near `:2718`) refuses + the arm that would leave more taps armed (`ObservationRefusal::Limit`, near `:2835-2845`). +- **What it costs.** Real IDs are short. A 64-track session with a compressor insert on every + track already needs 64 taps, more than the browser's 51, and the C ABI at 4 KiB allows 12. +- **The event.** A commit that ends bindings emits one `OBSERVATION_ENDED` with every ended key, in + key order, after its `SESSION_COMMITTED` (#1584 D6a, root ruling A). The room is reserved with + the commit before anything is prepared (`attach_observation_ended`, `control.rs` near + `:1616-1660`); an event over one frame is refused as a resource fault, a defence only. +- **The reserve.** `ended_event_ceiling(bindings)` (`compile.rs` near `:590-603`) charges each key + as `size_of::()` plus two maximum IDs, and the binding table rows + (`observation_binding_rows`) the same. + +## Decisions frozen for this slice + +- **D1. Admit by bytes.** The session keeps the exact encoded size of the `OBSERVATION_ENDED` + frame that would carry every armed key (the frame with no key, plus each armed key's own encoded + bytes, with the real strip and effect IDs). A batch is admitted when that size, after every + removal and arm of the batch, is at most the control frame limit. The arm that crosses is refused + with `ObservationRefusal::Limit` (its index; the reason names bytes, not a key count), with + nothing queued and no state moved. One naming per tap and the room check stay as they are. +- **D2. No allocation.** The per-key size is computed from the key's lengths with the codec's own + size rules (no encode into a buffer), so admission stays allocation-free in the browser + worklet's message handler. +- **D3. One event per commit.** Ruling A stands: a commit emits at most one `OBSERVATION_ENDED`, + and every binding a commit can end is armed, so its keys are a subset of the admitted set and + the event always fits. The room is still reserved at commit, before anything is prepared. +- **D4. The reserve follows the bytes.** The ended-event heap and the binding table rows charge + what the admitted keys can own, as ceilings. `observation_cap()` and its key-count figures are + removed with the docs that state them. + +## Deliverables + +1. D1 to D4 in `crates/control-plane`. +2. The browser's `observationLimit` reason (15) and its message keep their meaning; the SDK hint + text says the frame is full, not that a count is reached. +3. Docs: `docs/CONTROL_PROTOCOL_SEMANTICS.md` (the observation paragraph's 3/12/51 figures). + +## Authorized paths + +- `crates/control-plane/src/{control.rs,compile.rs}`, `crates/control-plane/tests/observation_bindings.rs` +- `hosts/host-web/src/lib.rs` (comments only), `hosts/host-web/src/tests.rs`, + `hosts/host-web/tests/control_parity.rs` +- `sdk/src/core/observation-subscriptions.ts` (the hint text only), its eval +- `docs/CONTROL_PROTOCOL_SEMANTICS.md` + +## Non-goals + +- A second `OBSERVATION_ENDED` per commit, or splitting the event over frames. +- A larger control frame. +- The C ABI observe edit itself (#1584 D1-D5); it calls the same core admission (#1584's batch 13b + amendment). + +## Objective gates + +1. **Large session.** A 64-track session with one compressor insert per track subscribes the + compressor's meter tap on every insert at the browser's 16 KiB frame: all 64 admitted; on the C + ABI (no default frame: the host sets `maximum_control_frame_bytes`; the capi tests use 4 KiB), + the same, through #1584's observe edit when it is on `main`, else at the core through + `SessionState` with a 4 KiB limit. +2. **The edge.** Keys sized so that the frame holds exactly `k` of them: `k` arms are admitted, + arm `k + 1` is refused `Limit` at its index, a disarm in the same batch makes room, and removing + the strips commits with one `OBSERVATION_ENDED` that holds all `k` keys and fits the frame. +3. **Realtime.** Admission allocates nothing (`bench_support::alloc` thread counters, statics + warmed). +4. Commands: `cargo test --locked -p control-plane --features test-support`, + `cargo test --locked -p host-web --features host-web/test-support`, the browser qualification, + clippy `-D warnings`, `fmt --check`. + +## Test value + +- Gate 1: red if admission still assumes 127-byte IDs (64 taps are refused). +- Gate 2: red if the byte count is off by one key or by the frame header, or if the commit can + emit an event over one frame. +- Gate 3: red if admission encodes into an allocated buffer. + +## Dependencies + +- #1676 and #1677 (the cap, the stable key; batch 13b). +- #1584 D6a (one event per commit). diff --git a/.github/ISSUE_SPECS/1686-remove-the-redundant-span-kind-and-end-from-prepared-automation-spans.md b/.github/ISSUE_SPECS/1686-remove-the-redundant-span-kind-and-end-from-prepared-automation-spans.md new file mode 100644 index 000000000..e719054c6 --- /dev/null +++ b/.github/ISSUE_SPECS/1686-remove-the-redundant-span-kind-and-end-from-prepared-automation-spans.md @@ -0,0 +1,93 @@ +# Remove the redundant span kind and end from prepared automation spans + +Successor of #1636 (retire the span kinds and sample rate no producer uses). Code anchors verified +on the batch 13b fold branch `codex/controls-b13b-fold`. Issue number: assigned at filing (written +here as #1686). Feature sprint mode (owner, 2026-10-13): one implementer, normal tests, a batch +review. + +## Product outcome + +An effect's automation input says only what the engine delivers: one value for one parameter +channel at one sample. No field can describe a span shape that no producer makes, so no effect and +no test has to check one. + +## Context + +- **The struct.** `PreparedAutomationSpan` (`crates/effect-contract/src/lib.rs`, near + `:1650-1658`) has `kind: AutomationSpanKind`, `channel`, `parameter_index`, `start_sample`, + `end_sample`, `start_value` and `end_value`. It is a plain Rust struct (no `repr(C)`), not on a + wire or the C ABI. +- **Only `Point` is left.** After #1636, `AutomationSpanKind` is `{Point=1}` (`lib.rs` near `:426`; + `from_raw` refuses 2 to 4). `valid_runtime_span` (near `:1806-1834`) checks that a `Point` has + `start_sample == end_sample` and equal value bits, so `kind`, `end_sample` and `end_value` carry no + information: a valid span always repeats its start in its end. +- **Who builds and reads spans.** Live records stage only `Point` spans + (`crates/effect-contract/src/live.rs`, near `:1315-1325`), and stored automation stages only + `Point` spans (#1058 A1.6). Construction and read sites are in `crates/effect-contract`, + `effect-compiler`, `rack`, `graph`, `graph-compiler`, `host-core`, `conformance`, each native + effect crate (`compressor`, `delay`, `gate-expander`, `multiband-compressor`, `parametric-eq`, + `soft-clip`, `transient-shaper`, `true-peak-limiter`), and `tools/{audit,console-workload,parameter-metadata}`. + A test that exists only to check a mismatched end (the delay's malformed-span test as #1636 left + it: a `Point` whose end sample differs from its start) has nothing left to check. + +## Decisions frozen for this slice + +- **D1. Remove the fields.** `PreparedAutomationSpan` loses `kind`, `end_sample` and `end_value`. + `valid_runtime_span` loses the checks that read them. `validate_automation_block`'s ordering + rule (near `:1869-1872`, which compares a span's start with the previous span's `end_sample` and + `end_value`) reads the previous span's sample and value instead, with the same verdict for every + valid `Point`. +- **D2. Rename what is left.** `start_sample` becomes `sample` and `start_value` becomes `value` + (a start names nothing without an end). The rename is mechanical. +- **D3. The kind enum.** `AutomationSpanKind` (and its alias `AutomationKind`) is deleted when no + other type reads it; its retired raw values stay documented as retired where #1636 put that note. +- **D4. Tests.** A test that exists only for a removed field is deleted in the same PR; a fixture + that sets the removed fields drops them. + +## Deliverables + +1. D1 to D4 across the construction and read sites named in Context. +2. `docs/EFFECT_CONTRACT_V1.md`: the span description names one sample and one value. + +## Authorized paths + +- `crates/effect-contract/src/`, `crates/effect-contract/tests/` +- The construction and read sites only, in `crates/{effect-compiler,rack,graph,graph-compiler,host-core,conformance}` + and the eight native effect crates (sources and tests) +- `tools/{audit,console-workload,parameter-metadata}` (span construction only) +- `docs/EFFECT_CONTRACT_V1.md` + +## Non-goals + +- Any change to how an effect applies a `Point` (smoothing, ramps, counters). +- New span kinds. + +## Hazards + +- **Retained bytes.** The struct shrinks, so a staging window's bytes shrink. Ceilings stay; a test + that pins an exact byte count of a span window is re-pinned with the reason + "#1686 removes the span kind and end". + +## Objective gates + +1. **No rendered bit moves.** `cargo build --locked --release -p audit -p capi && + ./target/release/audit capi` reports the same `pcm_digest` at base and head (PR evidence), and + the browser legs pass with unchanged digests. +2. **Contract.** The effect-contract truth table for `valid_runtime_span` keeps every case that + still has a field to read (non-finite value, sample outside the block, value outside the + domain, channel against the parameter's channel policy), and `validate_automation_block` + gives the same verdicts on its ordering cases (spans out of order; two spans at one sample with + different values). +3. Commands: `cargo test --locked --workspace` (or the `test-debug-a` and `test-debug-b` legs), + the randomized conformance run, `bash scripts/check-effect-runtime-policy.sh`, + `bash scripts/check-workspace-policy.sh`, clippy `-D warnings`, `fmt --check`. + +## Test value + +- Gate 1: red if removing the fields changed what an effect applies. +- Gate 2: red if a check on a remaining field was dropped with the removed ones, or if the ordering + rule changed its verdict when it lost the end fields. + +## Dependencies + +- #1636 (batch 13b). diff --git a/Cargo.lock b/Cargo.lock index 9476f3e8a..51e83fb0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,15 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "automation" +version = "0.1.0" +dependencies = [ + "bench-support", + "math", + "sha2 0.11.0", +] + [[package]] name = "base64" version = "0.22.1" @@ -2354,6 +2363,7 @@ dependencies = [ name = "wasm-gate-corpus" version = "0.1.0" dependencies = [ + "automation", "builtins", "compressor", "delay", @@ -2381,6 +2391,7 @@ dependencies = [ name = "wasm-gates" version = "0.1.0" dependencies = [ + "automation", "bench-support", "builtins", "compressor", diff --git a/Cargo.toml b/Cargo.toml index cde34dd67..7174fa99e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/math", "crates/session", "crates/controls", + "crates/automation", "crates/source", "crates/effect-contract", "crates/effect-runtime", @@ -60,6 +61,7 @@ dsp-reference = { path = "crates/dsp-reference", version = "0.1.0" } conformance = { path = "crates/conformance", version = "0.1.0" } session = { path = "crates/session", version = "0.1.0" } controls = { path = "crates/controls", version = "0.1.0" } +automation = { path = "crates/automation", version = "0.1.0" } source = { path = "crates/source", version = "0.1.0" } protocol = { path = "crates/protocol", version = "0.1.0" } capi = { path = "crates/capi", version = "0.1.0" } diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml new file mode 100644 index 000000000..754e50965 --- /dev/null +++ b/crates/automation/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "automation" +version.workspace = true +license.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[lib] +name = "automation" + +# A leaf: the stored-automation evaluator and event generator (#1637). It reads a segment table +# and writes a fixed state, so it depends on `math` alone (`pow`, scalar `f64`) and links `alloc` +# for the builder only. +[dependencies] +math.workspace = true + +[dev-dependencies] +# The audited allocator's thread-scoped counters: event generation allocates nothing, and the +# builder's byte report is what it leaves live. +bench-support.workspace = true +# The corpus pins are SHA-256 digests of the result words. +sha2.workspace = true + +[lints] +workspace = true diff --git a/crates/automation/src/corpus.rs b/crates/automation/src/corpus.rs new file mode 100644 index 000000000..b3d26e90d --- /dev/null +++ b/crates/automation/src/corpus.rs @@ -0,0 +1,282 @@ +//! The cross-target determinism corpus of the automation events (gate G5). +//! +//! Each case builds a fixed curve set, runs [`CellEvents`] over a fixed sequence of node-time +//! mappings and returns one `u32` word group per event: the block index, the offset, the kind +//! (with the held flag in bit 8), the ramp and the value bits. Gate G5 +//! (`tools/wasm-gates/tests/g5_native_corpus.rs`) digests the words with SHA-256 and compares +//! them with [`G5_DIGESTS`] natively; `tools/wasm-gate-corpus` replays the identical cases on +//! wasm32 against the same pins, and the AArch64 release job runs G5 unfiltered. +//! +//! The case is scalar: curve values are `f64` through `math::pow`, rounded once to `f32`, with no +//! lane type, so a digest does not depend on a lane width. Every table and mapping is built from +//! integer constants, and no value is NaN (the builder refuses a non-positive exponential). +//! +//! The pins were generated once from this scalar code on x86-64 and frozen; a mismatch is never +//! fixed by re-pinning. AArch64 cannot be run on the authoring host: the pins describe scalar +//! `f64` arithmetic, which the three targets must agree on. + +use alloc::vec::Vec; + +use crate::{ + CellDescription, CellEvents, CellState, Completion, EventKind, JumpLength, NodeSpan, Program, + ProgramBuilder, SeekMode, Segment, Shape, +}; + +/// Number of corpus cases. +pub const CASE_COUNT: usize = 6; + +/// Human-readable name of each case, indexed by case number. +pub const CASE_NAMES: [&str; CASE_COUNT] = [ + "automation/linear_db_fade", + "automation/exponential_sweep", + "automation/steps_gaps_and_a_reported_ramp", + "automation/exact_seeks", + "automation/ramped_seeks", + "automation/no_restart", +]; + +const fn seg(start: u64, end: u64, v0: f32, v1: f32, shape: Shape) -> Segment { + Segment { + start, + end, + start_value: v0, + end_value: v1, + shape, + } +} + +/// One case's curve table and cell parameters. +struct Case { + table: Vec, + ramp: u32, + jump: JumpLength, + completion: Completion, + no_restart: bool, + mode: SeekMode, +} + +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 => ( + alloc::vec![seg(0, 48_000, 0.0, -60.0, Shape::Linear)], + 64, + JumpLength::Block, + Completion::End, + false, + SeekMode::Exact, + ), + // A 20 Hz to 20 kHz exponential sweep over one second, a filter cell. + 1 => ( + alloc::vec![seg(0, 48_000, 20.0, 20_000.0, Shape::Exponential)], + 64, + JumpLength::Fixed(64), + Completion::After, + false, + SeekMode::Exact, + ), + // Steps, gaps, adjacent segments, an equal-valued join and an exponential, with a ramp + // the caller reports. + 2 => ( + alloc::vec![ + seg(100, 1000, 0.5, 0.75, Shape::Step), + seg(1000, 1500, 0.75, 0.25, Shape::Linear), + seg(2000, 2400, 0.25, 0.25, Shape::Step), + seg(2400, 3000, 2.0, 8.0, Shape::Exponential), + seg(3180, 3250, 1.0, 0.0, Shape::Step), + seg(3250, 9000, 0.0, 1.0, Shape::Linear), + ], + 64, + JumpLength::Block, + Completion::End, + false, + SeekMode::Exact, + ), + 3 | 4 => ( + alloc::vec![ + seg(0, 6000, -30.0, 6.0, Shape::Linear), + seg(6000, 6100, 0.0, 0.0, Shape::Step), + seg(6500, 20_000, 1.0, 1000.0, Shape::Exponential), + ], + 64, + JumpLength::Block, + Completion::End, + false, + if case == 3 { + SeekMode::Exact + } else { + SeekMode::Ramp + }, + ), + // The delay time: a grid and ramp of 128, a jump that waits for its ramp. + _ => ( + alloc::vec![ + seg(0, 100, 10.0, 10.0, Shape::Step), + seg(100, 190, 20.0, 20.0, Shape::Step), + seg(190, 300, 30.0, 30.0, Shape::Step), + seg(300, 5000, 5.0, 500.0, Shape::Exponential), + seg(5050, 5400, 40.0, 41.0, Shape::Step), + ], + 128, + JumpLength::Fixed(128), + Completion::End, + true, + SeekMode::Exact, + ), + }; + Case { + table, + ramp, + jump, + completion, + no_restart, + mode, + } +} + +/// One block of a case's fixed mapping. +struct Block { + span: NodeSpan, + jump: u32, + /// A ramp to report to the state before the block, as a completion node time. + hold: Option, +} + +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) { + out.push(Block { span, jump, hold }); + } + }; + match case { + 0 => { + // Quantum 100 from before the curve, so the grid straddles blocks. + for index in 0..490_i64 { + push(index * 100 - 50, None, 100, 480, None); + } + } + 1 => { + for index in 0..400_i64 { + push(index * 128, None, 128, 64, None); + } + } + 2 => { + for index in 0..80_i64 { + let hold = (index % 9 == 4).then_some(index * 128 + 300); + let jump = if index % 2 == 0 { 480 } else { 960 }; + push(index * 128, None, 128, jump, hold); + } + } + 3 | 4 => { + // Seeks at offset 0, mid-block and the last sample, forward and backward, each + // followed by plain blocks. + let mut node = 0_i64; + for round in 0..24_i64 { + let target = (round * 4_813 + 217) % 21_000; + let offset = [0, 37, 99, 63][(round % 4) as usize]; + let jump = [480, 960, 240][(round % 3) as usize]; + push(node, Some((offset, target)), 100, jump, None); + node = target + i64::from(100 - offset); + for _ in 0..6 { + push(node, None, 100, jump, None); + node += 100; + } + } + } + _ => { + for index in 0..60_i64 { + push(index * 100, None, 100, 0, None); + } + } + } + out +} + +/// The result words of one case: five per event, then the event count. +/// +/// # Panics +/// +/// Panics if `case >= CASE_COUNT`, or if the frozen description is refused (a corpus bug). +#[must_use] +pub fn run_case(case: usize) -> Vec { + assert!(case < CASE_COUNT, "corpus case index out of range"); + let spec = case_of(case); + let description = CellDescription { + segments: &spec.table, + grid_ramp: spec.ramp, + grid: spec.ramp.max(64), + jump: spec.jump, + completion: spec.completion, + no_restart: spec.no_restart, + }; + let program: Program = + ProgramBuilder::build(&[description]).expect("the frozen corpus description is valid"); + let cell = program.cell(0).expect("one cell"); + let mut state = CellState::new(); + let mut words = Vec::new(); + for (index, block) in blocks(case).into_iter().enumerate() { + if let Some(completion) = block.hold { + state.hold_until(completion); + } + for event in CellEvents::new(cell, &mut state, block.span, spec.mode, block.jump) { + let kind = match event.kind { + EventKind::Grid => 0, + EventKind::Jump => 1, + EventKind::Set => 2, + }; + words.extend_from_slice(&[ + index as u32, + event.offset, + kind | (u32::from(event.held) << 8), + event.ramp, + event.value.to_bits(), + ]); + } + } + let count = (words.len() / 5) as u32; + words.push(count); + words +} + +/// SHA-256 of each case's result words, little-endian, generated once from this scalar code on +/// `x86_64` and frozen. A mismatch is never fixed by re-pinning: it means the corpus changed or a +/// target stopped agreeing on scalar `f64` arithmetic. +pub const G5_DIGESTS: [[u8; 32]; CASE_COUNT] = [ + // automation/linear_db_fade + [ + 0xBD, 0xF6, 0xDF, 0xE1, 0x91, 0x89, 0x30, 0xC6, 0x3A, 0x42, 0xB2, 0xA2, 0xE5, 0x3B, 0xB4, + 0xA9, 0x3B, 0x35, 0x53, 0x52, 0xAF, 0x5E, 0xE9, 0xE3, 0x85, 0x33, 0x84, 0xA9, 0xD6, 0x64, + 0x7B, 0xCB, + ], + // automation/exponential_sweep + [ + 0x91, 0x6D, 0x5F, 0x88, 0xB6, 0x70, 0x99, 0x85, 0x30, 0x35, 0xCB, 0xD0, 0x17, 0x72, 0x3B, + 0xAA, 0x39, 0x69, 0xEB, 0x59, 0xFB, 0x30, 0x95, 0xF3, 0x8F, 0xF5, 0x54, 0xD2, 0xDF, 0xC5, + 0xD7, 0xD2, + ], + // automation/steps_gaps_and_a_reported_ramp + [ + 0x94, 0x5C, 0x0A, 0xC9, 0xF1, 0x3B, 0x56, 0xED, 0xE8, 0xBA, 0xDF, 0x99, 0xCE, 0xAC, 0x30, + 0x00, 0x71, 0x06, 0x79, 0x60, 0x76, 0x55, 0x25, 0x73, 0xF4, 0xCF, 0x65, 0xF8, 0x4E, 0x4F, + 0xA3, 0x22, + ], + // automation/exact_seeks + [ + 0x54, 0xCF, 0xA6, 0xB9, 0x9B, 0x82, 0x02, 0xE8, 0x61, 0x64, 0xB8, 0x9F, 0x98, 0xF6, 0x1F, + 0x42, 0x64, 0x95, 0xBF, 0xFE, 0x27, 0xDC, 0xAD, 0xE8, 0x34, 0x53, 0xE4, 0xDE, 0x2F, 0x37, + 0xA4, 0xDA, + ], + // automation/ramped_seeks + [ + 0x55, 0xC7, 0x97, 0x52, 0xE1, 0x5A, 0xC2, 0x7A, 0xB0, 0xEB, 0xBC, 0xCC, 0xA6, 0xCF, 0x9C, + 0x71, 0xF8, 0x36, 0xD5, 0x0D, 0x1C, 0x5B, 0x56, 0x8D, 0x9F, 0x3F, 0x1A, 0x58, 0x0E, 0xA5, + 0xE6, 0x45, + ], + // automation/no_restart + [ + 0xA5, 0xB7, 0x56, 0xAB, 0xAB, 0x96, 0xF5, 0x0D, 0x2A, 0x58, 0x9C, 0x9E, 0xB2, 0x36, 0x26, + 0x29, 0x2F, 0xDB, 0x2B, 0x96, 0xC1, 0x68, 0xEC, 0xCE, 0xAB, 0x38, 0x50, 0xCE, 0x4E, 0x68, + 0xB0, 0x8F, + ], +]; diff --git a/crates/automation/src/evaluate.rs b/crates/automation/src/evaluate.rs new file mode 100644 index 000000000..f6e853b9b --- /dev/null +++ b/crates/automation/src/evaluate.rs @@ -0,0 +1,51 @@ +//! The evaluation law: one segment table, one node time, one `f32`. + +use crate::{Segment, Shape}; + +// REALTIME_POLICY_BEGIN +/// The curve's value at node time `t`. +/// +/// The hold rule: before the first segment the first `start_value`; inside a segment its shape; at +/// and after a segment's `end` (and in a gap) its `end_value`. Inside a `linear` or `exponential` +/// segment `x = (t - start) as f64 / (end - start) as f64` (both differences are exact below +/// `2^53`), `linear` is `v0 + (v1 - v0)·x` and `exponential` is `v0·math::pow(v1/v0, x)`, all in +/// `f64` and rounded once to `f32`. A table with no segment holds `0.0`; the builder refuses one. +/// +/// The segments must be ordered and disjoint (the builder and the session validator hold that) and +/// an `exponential` segment's values positive; a table that breaks either gives an unspecified +/// value, never a panic. +#[must_use] +pub fn value_at(segments: &[Segment], t: i64) -> f32 { + let Some(first) = segments.first() else { + return 0.0; + }; + let Ok(t) = u64::try_from(t) else { + return first.start_value; + }; + let Some(index) = segments + .partition_point(|segment| segment.start <= t) + .checked_sub(1) + else { + return first.start_value; + }; + let Some(segment) = segments.get(index) else { + return first.start_value; + }; + if t >= segment.end { + return segment.end_value; + } + let v0 = f64::from(segment.start_value); + let v1 = f64::from(segment.end_value); + match segment.shape { + Shape::Step => segment.start_value, + Shape::Linear => (v0 + (v1 - v0) * fraction(segment, t)) as f32, + Shape::Exponential => (v0 * math::pow(v1 / v0, fraction(segment, t))) as f32, + } +} + +/// `x = (t - t0) / (t1 - t0)` for `t` inside the segment, so the divisor is positive. +#[inline] +fn fraction(segment: &Segment, t: u64) -> f64 { + (t - segment.start) as f64 / (segment.end - segment.start) as f64 +} +// REALTIME_POLICY_END diff --git a/crates/automation/src/events.rs b/crates/automation/src/events.rs new file mode 100644 index 000000000..38de26e1c --- /dev/null +++ b/crates/automation/src/events.rs @@ -0,0 +1,498 @@ +//! Event generation: one cell, one block, pulled in offset order. + +use crate::{CellProgram, Completion, JumpLength, value_at}; + +/// A cell with no ramp in flight. +const IDLE: i64 = i64::MIN; +/// A cursor not yet placed in the discontinuity list. +const UNPOSITIONED: u32 = u32::MAX; + +/// The kind of one event. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EventKind { + /// A retarget on the node-time grid, along the curve. + Grid, + /// A retarget at a discontinuity of the table, or the ramped landing of a seek. + Jump, + /// An exact value, no ramp: the landing of a seek in [`SeekMode::Exact`]. + Set, +} + +/// One event of a block. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Event { + /// The sample of the block the event applies at. + pub offset: u32, + /// The target value. + pub value: f32, + /// The ramp length in samples; `0` for [`EventKind::Set`]. + pub ramp: u32, + /// What produced the event. + pub kind: EventKind, + /// A grid event inside a ramp that completes later: the stage remembers `value` and does not + /// retarget, and the cell's current target does not change. + pub held: bool, +} + +/// How a seek lands. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SeekMode { + /// An exact [`EventKind::Set`] (builtin cells). + Exact, + /// A [`EventKind::Jump`] over the jump length (effect cells). + Ramp, +} + +/// Why a [`NodeSpan`] is refused. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SpanError { + /// The seek step's offset is not inside the block. + StepOutsideBlock, +} + +// REALTIME_POLICY_BEGIN +/// One node block's mapping from offset to node time: the node time at offset 0, at most one step +/// (a seek) and the block length. +/// +/// Between the start and the step, and from the step on, node time advances by one per sample. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct NodeSpan { + start: i64, + step: Option<(u32, i64)>, + frames: u32, +} + +impl NodeSpan { + /// A span of `frames` samples whose offset 0 is node time `start`, with an optional seek step + /// `(offset, node_time)`. A step at offset 0 replaces `start`. + /// + /// # Errors + /// + /// [`SpanError::StepOutsideBlock`] when the step offset is not below `frames`. + pub const fn new(start: i64, step: Option<(u32, i64)>, frames: u32) -> Result { + if let Some((offset, _)) = step + && offset >= frames + { + return Err(SpanError::StepOutsideBlock); + } + Ok(Self { + start, + step, + frames, + }) + } +} + +/// The ramp in flight, the current target and the cursor of one cell, kept across blocks. +/// +/// Its size is a fixed 32 bytes. A fresh state has no target, so the first retarget always +/// emits. +#[derive(Clone, Copy, Debug)] +pub struct CellState { + /// Index of the next unconsumed discontinuity; [`UNPOSITIONED`] before the first block. + cursor: u32, + /// The bits of the current target, valid when `has_target`. + target: u32, + has_target: bool, + /// A held event is outstanding, so the next retarget emits even when its value is unchanged. + held: bool, + /// The node time at which the ramp in flight completes, or [`IDLE`]. + ramp_done: i64, + /// The node time at which a deferred jump fires (a `no_restart` cell), or [`IDLE`]. + deferred: i64, +} + +const _: () = assert!(size_of::() <= 48); + +impl Default for CellState { + fn default() -> Self { + Self::new() + } +} + +impl CellState { + /// A state with no target, no ramp in flight and an unplaced cursor. + #[must_use] + pub const fn new() -> Self { + Self { + cursor: UNPOSITIONED, + target: 0, + has_target: false, + held: false, + ramp_done: IDLE, + deferred: IDLE, + } + } + + /// Places the cursor for node time `t` (an adoption): the next block's discontinuities start + /// at `t`. The in-flight ramp and a deferred jump belong to the old position and are dropped, + /// and an outstanding held event is cleared, so the next retarget to an unchanged value emits + /// nothing; the current target is kept. + pub fn reposition(&mut self, program: &CellProgram<'_>, t: i64) { + self.cursor = to_cursor(program.discontinuities.partition_point(|&time| time < t)); + self.ramp_done = IDLE; + self.deferred = IDLE; + self.held = false; + } + + /// Reports a ramp the caller started on this lane (a mute ramp, a live record's ramp) that + /// completes at node time `completion`, so the grid holds inside it. + /// + /// On a `no_restart` cell the caller must not restart a busy lane, so `completion` is never + /// earlier than the ramp in flight: a deferred jump keeps the absolute time it was deferred + /// to, and a shorter report would land it late (at the next grid point instead). + pub const fn hold_until(&mut self, completion: i64) { + self.ramp_done = completion; + } + + /// The bits of the current target, if the cell has one. + #[must_use] + pub const fn target(&self) -> Option { + if self.has_target { + Some(f32::from_bits(self.target)) + } else { + None + } + } +} + +fn to_cursor(index: usize) -> u32 { + u32::try_from(index).unwrap_or(UNPOSITIONED - 1) +} + +/// The node time at which a ramp of `n` samples started at `start` completes. +/// +/// `start + n - 1` for [`Completion::End`] and `start + n` for [`Completion::After`]; both are +/// `start` for `n = 0`. +#[must_use] +pub const fn completion_of(completion: Completion, start: i64, n: u32) -> i64 { + if n == 0 { + return start; + } + let after = start.saturating_add(n as i64); + match completion { + Completion::End => after - 1, + Completion::After => after, + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Stage { + /// The samples before the seek step, or the whole block. + Before, + /// The landing of the seek step. + Seek, + /// The samples after the seek step. + After, + Done, +} + +/// The events of one block of one cell, in increasing offset, at most one per offset +/// (`Set > Jump > Grid`). Consume it to the end: the state advances as events come out. +#[derive(Debug)] +pub struct CellEvents<'a, 's> { + program: CellProgram<'a>, + state: &'s mut CellState, + mode: SeekMode, + jump: u32, + span: NodeSpan, + stage: Stage, + /// First offset of the current run, its end, the node time at its first offset, and the next + /// offset to examine. + run_start: u32, + run_end: u32, + run_time: i64, + position: u32, +} + +impl<'a, 's> CellEvents<'a, 's> { + /// The events of the block `span` of `program`. + /// + /// `jump` is the block's jump length for a [`JumpLength::Block`] cell (the caller resolves the + /// cell's row against the session's ramp lengths); a [`JumpLength::Fixed`] cell ignores it. + #[must_use] + pub fn new( + program: CellProgram<'a>, + state: &'s mut CellState, + span: NodeSpan, + mode: SeekMode, + jump: u32, + ) -> Self { + let before_end = span.step.map_or(span.frames, |(offset, _)| offset); + if state.cursor == UNPOSITIONED && before_end > 0 { + // Only the cursor: a ramp the caller reported before the first block stays reported. + state.cursor = to_cursor(program.discontinuities.partition_point(|&t| t < span.start)); + } + let stage = if before_end > 0 { + Stage::Before + } else if span.step.is_some() { + Stage::Seek + } else { + Stage::Done + }; + Self { + program, + state, + mode, + jump, + span, + stage, + run_start: 0, + run_end: before_end, + run_time: span.start, + position: 0, + } + } + + fn jump_length(&self) -> u32 { + match self.program.jump { + JumpLength::Block => self.jump, + JumpLength::Fixed(length) => length, + } + } + + fn completion(&self, start: i64, n: u32) -> i64 { + completion_of(self.program.completion, start, n) + } + + /// 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, + } + } + + /// 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) + } + + /// `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 + } + + 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) + }; + } + + /// The landing of the seek step at `offset`, node time `time`. + fn land(&mut self, offset: u32, time: i64) -> Event { + 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; + match self.mode { + SeekMode::Exact => { + let value = value_at(self.program.segments, time); + self.commit(value, time, 0); + Event { + offset, + value, + ramp: 0, + kind: EventKind::Set, + held: false, + } + } + SeekMode::Ramp => { + let ramp = self.jump_length(); + let value = self.target_at(time, ramp); + self.commit(value, time, ramp); + Event { + offset, + value, + ramp, + kind: EventKind::Jump, + held: false, + } + } + } + } + + fn jump_event(&mut self, offset: u32, time: i64) -> Option { + let ramp = self.jump_length(); + let value = self.target_at(time, ramp); + // A ramp in flight that completes after this jump would is still short of its target at + // 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); + if self.unchanged(value) && !lagging { + return None; + } + self.commit(value, time, ramp); + Some(Event { + offset, + value, + ramp, + kind: EventKind::Jump, + held: false, + }) + } + + fn grid_event(&mut self, offset: u32, time: i64) -> Option { + let ramp = self.program.ramp; + let value = self.target_at(time, ramp); + let done = self.completion(time, ramp); + let in_flight = time < self.free_at(); + if in_flight && (self.program.no_restart || done < self.state.ramp_done) { + let differs = !self.state.has_target || self.state.target != value.to_bits(); + if !differs && !self.state.held { + return None; + } + self.state.held = differs; + return Some(Event { + offset, + value, + ramp, + kind: EventKind::Grid, + held: true, + }); + } + if self.unchanged(value) { + return None; + } + self.commit(value, time, ramp); + Some(Event { + offset, + value, + ramp, + kind: EventKind::Grid, + held: false, + }) + } + + /// Steps the cursor over the discontinuities before `here`, which the caller passed by moving + /// node time forward with no seek step. Their jumps are dropped: the next grid event retargets + /// along the curve. A binary search, so a long skip costs `O(log n)`. + fn skip_behind(&mut self, here: i64) { + let cursor = self.state.cursor as usize; + if let Some(ahead) = self.program.discontinuities.get(cursor..) + && ahead.first().is_some_and(|&time| time < here) + { + self.state.cursor = to_cursor(cursor + ahead.partition_point(|&time| time < here)); + } + } + + /// The next event of the current run, or `None` at its end. + fn next_in_run(&mut self) -> Option { + loop { + if self.position >= self.run_end { + return None; + } + let here = self.run_time + i64::from(self.position - self.run_start); + self.skip_behind(here); + let period = i64::from(self.program.grid); + let grid = match here.rem_euclid(period) { + 0 => here, + rest => here.saturating_add(period - rest), + }; + let boundary = self + .program + .discontinuities + .get(self.state.cursor as usize) + .copied(); + // A deferral behind the run (the caller moved node time forward with no seek step) is + // still owed: it is due at the run's first sample. Inside a run a deferral is always + // ahead, since it is set to a free time past the sample that deferred it. + let deferred = (self.state.deferred != IDLE).then(|| self.state.deferred.max(here)); + let jump = boundary.into_iter().chain(deferred).min(); + let next = jump.map_or(grid, |jump| jump.min(grid)); + let remaining = i64::from(self.run_end - self.position); + // Every candidate is at or after `here`, so `distance` is not negative. + let distance = next.saturating_sub(here); + if distance >= remaining { + return None; + } + let Ok(distance) = u32::try_from(distance) else { + return None; + }; + let offset = self.position + distance; + let is_boundary = boundary == Some(next); + let is_deferred = deferred == Some(next); + if (is_boundary || is_deferred) && self.program.no_restart && next < self.free_at() { + // The ramp cannot restart: the jump waits for the lane to be free. A deferral due + // now waits again when the caller reported a ramp after it was deferred + // (`hold_until` between blocks), including one due at a run's first sample. + if is_boundary { + self.state.cursor += 1; + } + self.state.deferred = self.free_at(); + continue; + } + let is_jump = is_boundary || is_deferred; + if is_boundary { + self.state.cursor += 1; + } + if is_deferred { + self.state.deferred = IDLE; + } + self.position = offset + 1; + let event = if is_jump { + self.jump_event(offset, next) + } else { + self.grid_event(offset, next) + }; + if event.is_some() { + return event; + } + } + } +} + +impl Iterator for CellEvents<'_, '_> { + type Item = Event; + + fn next(&mut self) -> Option { + loop { + match self.stage { + Stage::Done => return None, + Stage::Seek => { + let (offset, time) = self.span.step?; + self.stage = Stage::After; + self.run_start = offset; + self.run_end = self.span.frames; + self.run_time = time; + self.position = offset + 1; + return Some(self.land(offset, time)); + } + Stage::Before | Stage::After => { + if let Some(event) = self.next_in_run() { + return Some(event); + } + self.stage = if self.stage == Stage::Before && self.span.step.is_some() { + Stage::Seek + } else { + Stage::Done + }; + } + } + } + } +} +// REALTIME_POLICY_END diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs new file mode 100644 index 000000000..61e0f5731 --- /dev/null +++ b/crates/automation/src/lib.rs @@ -0,0 +1,109 @@ +//! Stored automation, evaluated and turned into per-cell events in node time (#1637). +//! +//! 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 uses it yet. +//! +//! # The law +//! +//! The curve holds the first segment's `start_value` before the first segment, a segment's +//! `end_value` after it and in a gap, and a `step` holds its `start_value` on `[start, end)`. +//! Inside a `linear` or `exponential` segment, `x = (t - t0) / (t1 - t0)` and the value is +//! `v0 + (v1 - v0)·x` or `v0·(v1/v0)^x` (the Web Audio ramp formulas). Every value is computed in +//! scalar `f64` through [`math::pow`] and rounded once to `f32`, so the same table and the same +//! node-time mapping give the same events, bit for bit, on x86-64, AArch64 and wasm, at any +//! quantum, wherever a seek lands. There is no lane math and no platform math here: events are +//! sparse control arithmetic, and vector lane math diverges on AArch64 release builds. +//! +//! # Events +//! +//! A cell has a fixed grid in node time (a multiple of `G` samples) and three kinds of event: +//! [`EventKind::Grid`] retargets along the curve, [`EventKind::Jump`] retargets at a +//! discontinuity of the table, and [`EventKind::Set`] sets the exact value at a seek. A block's +//! events come out of an iterator, in increasing offset, with no buffer. Because the grid is in +//! node time, not render time, the events do not depend on the quantum or on the render sample a +//! seek landed at. +//! +//! A block of `q` samples yields at most [`CellProgram::max_events`] events: `2⌈q/64⌉ + 3`, and +//! one more for a `no_restart` cell, whose jump deferred from an earlier block can fire in it. The +//! bound rests on the session's spacing rule (discontinuities at least 64 samples apart, #1626), +//! which [`ProgramBuilder`] enforces again ([`BuildError::Spacing`]). +//! +//! # Realtime +//! +//! Evaluation and event generation are render-reachable: they allocate, lock and block on +//! nothing. Only [`ProgramBuilder`] allocates; it runs on the control thread and reports its exact +//! bytes first. + +#![no_std] + +extern crate alloc; + +mod evaluate; +mod events; +mod program; + +pub mod corpus; + +pub use evaluate::value_at; +pub use events::{ + CellEvents, CellState, Event, EventKind, NodeSpan, SeekMode, SpanError, completion_of, +}; +pub use program::{ + BuildError, CellDescription, CellProgram, DISCONTINUITY_SPACING, Program, ProgramBuilder, +}; + +/// The shape of one segment. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum Shape { + /// Holds `start_value` on `[start, end)`, then `end_value`. + Step = 0, + /// `v0 + (v1 - v0)·x`. + Linear = 1, + /// `v0·(v1/v0)^x`; both values are positive. + Exponential = 2, +} + +/// One stored segment, the same fields as a session `AutomationSegment` in a neutral type. +/// +/// Times are node-time samples; the hold rule (see the crate documentation) fills the gaps. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Segment { + /// First sample of the segment. + pub start: u64, + /// First sample after the segment. + pub end: u64, + /// The value at `start`. + pub start_value: f32, + /// The value at `end`, and after it. + pub end_value: f32, + /// How the value moves from `start_value` to `end_value`. + pub shape: Shape, +} + +const _: () = assert!(size_of::() == 32); + +/// Where a ramp of `n` samples started at node time `τ` completes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Completion { + /// At `τ + n - 1`: the fader, matrix, trim and effect ramps assign the target on their last + /// frame. + End, + /// At `τ + n`: the EQ and input-filter coefficient ramps reach the target one sample later. + After, +} + +/// Where a cell's jump ramp length comes from. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum JumpLength { + /// The length the caller passes for the block: `controls::LiveRamps::for_row` of the cell's + /// live-ramp row (`effect_contract::control::LiveRampRow`). The row's rule lives in + /// `controls` alone, so this crate neither names a row nor writes its rule a second time, and + /// it stays a leaf that depends on `math` only. The program stores no copy of the length, so a + /// `control_smoothing` edit reaches the next jump with no rebuild. + Block, + /// A length fixed by the target: 64 for a filter, the smoothing of an effect parameter. + Fixed(u32), +} diff --git a/crates/automation/src/program.rs b/crates/automation/src/program.rs new file mode 100644 index 000000000..b28ede51e --- /dev/null +++ b/crates/automation/src/program.rs @@ -0,0 +1,355 @@ +//! The control-side builder and the immutable program render reads. + +use alloc::boxed::Box; +use alloc::vec::Vec; + +use crate::{Completion, JumpLength, Segment, Shape, value_at}; + +/// Times are exact in `f64` below this bound, which the evaluator relies on. +const TIME_LIMIT: u64 = 1 << 53; + +/// The least distance, in node-time samples, between two discontinuities of one cell: the session +/// refuses closer ones (`automation.discontinuity_spacing`, #1626 D3), and the builder refuses +/// them again, typed, because the per-block event bound ([`CellProgram::max_events`]) rests on it. +pub const DISCONTINUITY_SPACING: u32 = 64; + +/// One cell, in neutral terms: its table and the parameters of its events. +#[derive(Clone, Copy, Debug)] +pub struct CellDescription<'a> { + /// The cell's segments, ordered and disjoint, at least one. + pub segments: &'a [Segment], + /// `L`: the length of a grid ramp, in samples. + pub grid_ramp: u32, + /// `G`: the grid period in node-time samples. A multiple of 64 and exactly the larger of 64 + /// and `grid_ramp`. + pub grid: u32, + /// Where a jump's ramp length comes from. + pub jump: JumpLength, + /// Where a ramp started at `τ` completes. + pub completion: Completion, + /// A target whose ramp cannot restart while one runs (the delay time). + pub no_restart: bool, +} + +/// Why a description is refused. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BuildError { + /// The cell has no segment. + NoSegments { + /// The cell's index. + cell: usize, + }, + /// A segment has `end <= start`, a time at or past `2^53`, or a non-finite value. + Segment { + /// The cell's index. + cell: usize, + /// The segment's index. + segment: usize, + }, + /// A segment starts before the previous one ends. + Order { + /// The cell's index. + cell: usize, + /// The later segment's index. + segment: usize, + }, + /// An `exponential` segment has a value that is not positive. + NonPositiveExponential { + /// The cell's index. + cell: usize, + /// The segment's index. + segment: usize, + }, + /// `grid` is not a multiple of 64, or differs from the larger of 64 and `grid_ramp`. + Grid { + /// The cell's index. + cell: usize, + }, + /// Two discontinuities of the cell are closer than [`DISCONTINUITY_SPACING`] samples. + Spacing { + /// The cell's index. + cell: usize, + /// The node time of the later discontinuity of the first close pair. + time: i64, + }, + /// A count does not fit the program's `u32` indexes. + TooLarge, + /// The allocator refused the reservation. + Allocation, +} + +/// What one cell stores: ranges into the program's flat arrays and its event parameters. +#[derive(Clone, Copy, Debug)] +struct CellRecord { + segments: (u32, u32), + discontinuities: (u32, u32), + grid: u32, + ramp: u32, + jump: JumpLength, + completion: Completion, + no_restart: bool, +} + +/// The built, immutable cells of one plan. +#[derive(Debug)] +pub struct Program { + segments: Box<[Segment]>, + discontinuities: Box<[i64]>, + cells: Box<[CellRecord]>, +} + +/// A cell as render reads it: borrowed slices and parameters. +#[derive(Clone, Copy, Debug)] +pub struct CellProgram<'a> { + pub(crate) segments: &'a [Segment], + /// The node times at which the table is discontinuous, ascending and distinct. + pub(crate) discontinuities: &'a [i64], + pub(crate) grid: u32, + pub(crate) ramp: u32, + pub(crate) jump: JumpLength, + pub(crate) completion: Completion, + pub(crate) no_restart: bool, +} + +// REALTIME_POLICY_BEGIN +impl<'a> CellProgram<'a> { + /// The cell's segment slice, the argument of [`value_at`]. + #[must_use] + pub const fn segments(&self) -> &'a [Segment] { + self.segments + } + + /// The node times at which the table is discontinuous, ascending. + #[must_use] + pub const fn discontinuities(&self) -> &'a [i64] { + self.discontinuities + } + + /// `G`, the grid period. + #[must_use] + pub const fn grid(&self) -> u32 { + self.grid + } + + /// The most events one block of `frames` samples yields for this cell: the capacity a + /// per-cell event buffer needs. + /// + /// A block is at most two runs (before and after a seek step) of `a + b = frames - 1` samples, + /// or one run of `frames`. Each run holds at most `⌈run/64⌉` grid points (`G` is at least 64) + /// and, with discontinuities at least [`DISCONTINUITY_SPACING`] apart, `⌈run/64⌉` of them, and + /// `⌈a/64⌉ + ⌈b/64⌉ <= ⌈frames/64⌉ + 1`. The seek adds its landing. A `no_restart` cell can + /// also fire, in the run before the step, one jump deferred from an earlier block (it carries + /// every discontinuity its ramp covered, so later discontinuities of the run still come at + /// least 64 apart): `2⌈frames/64⌉ + 3`, plus one for a `no_restart` cell. + #[must_use] + pub const fn max_events(&self, frames: u32) -> u32 { + let per_kind = frames.div_ceil(64) + 1; + 2 * per_kind + 1 + if self.no_restart { 1 } else { 0 } + } +} + +impl Program { + /// The number of cells. + #[must_use] + pub fn len(&self) -> usize { + self.cells.len() + } + + /// `true` when the program has no cell. + #[must_use] + pub fn is_empty(&self) -> bool { + self.cells.is_empty() + } + + /// Cell `index` as render reads it. + #[must_use] + pub fn cell(&self, index: usize) -> Option> { + let record = self.cells.get(index)?; + let segments = self.segments.get(range(record.segments))?; + let discontinuities = self.discontinuities.get(range(record.discontinuities))?; + Some(CellProgram { + segments, + discontinuities, + grid: record.grid, + ramp: record.ramp, + jump: record.jump, + completion: record.completion, + no_restart: record.no_restart, + }) + } +} + +fn range((start, len): (u32, u32)) -> core::ops::Range { + let start = start as usize; + start..start + len as usize +} +// REALTIME_POLICY_END + +/// Builds a [`Program`] from cell descriptions. +#[derive(Clone, Copy, Debug)] +pub struct ProgramBuilder; + +impl ProgramBuilder { + /// The exact bytes [`Self::build`] allocates for `cells`, which preparation charges first. + #[must_use] + pub fn bytes(cells: &[CellDescription<'_>]) -> u64 { + let segments: u64 = cells.iter().map(|cell| cell.segments.len() as u64).sum(); + let discontinuities: u64 = cells + .iter() + .map(|cell| count_discontinuities(cell.segments) as u64) + .sum(); + segments * size_of::() as u64 + + discontinuities * size_of::() as u64 + + cells.len() as u64 * size_of::() as u64 + } + + /// Validates `cells` and builds their program, reserving each array once and fallibly. + /// + /// # Errors + /// + /// The first [`BuildError`] found; nothing is allocated before validation passes. + pub fn build(cells: &[CellDescription<'_>]) -> Result { + let mut segment_count = 0_usize; + let mut discontinuity_count = 0_usize; + for (index, cell) in cells.iter().enumerate() { + validate(index, cell)?; + segment_count += cell.segments.len(); + discontinuity_count += count_discontinuities(cell.segments); + } + // Positions are `u32`, and the unpositioned cursor sentinel is `u32::MAX`. + if u32::try_from(segment_count).is_err() || u32::try_from(discontinuity_count + 1).is_err() + { + return Err(BuildError::TooLarge); + } + let mut segments = reserve::(segment_count)?; + let mut discontinuities = reserve::(discontinuity_count)?; + let mut records = reserve::(cells.len())?; + for cell in cells { + let discontinuity_start = discontinuities.len(); + for_each_discontinuity(cell.segments, |time| discontinuities.push(time)); + records.push(CellRecord { + segments: (to_u32(segments.len()), to_u32(cell.segments.len())), + discontinuities: ( + to_u32(discontinuity_start), + to_u32(discontinuities.len() - discontinuity_start), + ), + grid: cell.grid, + ramp: cell.grid_ramp, + jump: cell.jump, + completion: cell.completion, + no_restart: cell.no_restart, + }); + segments.extend_from_slice(cell.segments); + } + Ok(Program { + segments: segments.into_boxed_slice(), + discontinuities: discontinuities.into_boxed_slice(), + cells: records.into_boxed_slice(), + }) + } +} + +/// Counts were checked against `u32` before any conversion. +fn to_u32(value: usize) -> u32 { + u32::try_from(value).unwrap_or(u32::MAX) +} + +fn reserve(count: usize) -> Result, BuildError> { + let mut vector = Vec::new(); + vector + .try_reserve_exact(count) + .map_err(|_| BuildError::Allocation)?; + Ok(vector) +} + +fn validate(cell: usize, description: &CellDescription<'_>) -> Result<(), BuildError> { + if description.segments.is_empty() { + return Err(BuildError::NoSegments { cell }); + } + if !description.grid.is_multiple_of(64) || description.grid != description.grid_ramp.max(64) { + return Err(BuildError::Grid { cell }); + } + let mut previous_end = 0; + for (segment, value) in description.segments.iter().enumerate() { + if value.start >= value.end + || value.end >= TIME_LIMIT + || !value.start_value.is_finite() + || !value.end_value.is_finite() + { + return Err(BuildError::Segment { cell, segment }); + } + if value.start < previous_end { + return Err(BuildError::Order { cell, segment }); + } + if value.shape == Shape::Exponential && !(value.start_value > 0.0 && value.end_value > 0.0) + { + return Err(BuildError::NonPositiveExponential { cell, segment }); + } + previous_end = value.end; + } + let mut previous = None; + let mut close = None; + for_each_discontinuity(description.segments, |time| { + if close.is_none() + && previous.is_some_and(|before: i64| time - before < i64::from(DISCONTINUITY_SPACING)) + { + close = Some(time); + } + previous = Some(time); + }); + if let Some(time) = close { + return Err(BuildError::Spacing { cell, time }); + } + Ok(()) +} + +fn count_discontinuities(segments: &[Segment]) -> usize { + let mut count = 0; + for_each_discontinuity(segments, |_| count += 1); + count +} + +/// Calls `emit` with each node time at which the table is discontinuous, ascending: where the +/// value just before the time differs, bit for bit, from the value at it. +/// +/// A discontinuity is a step the hold rule makes, not a fast ramp: a segment's start when the +/// value held before it is not its `start_value`, and a `step`'s end when `end_value` differs from +/// `start_value`. A `linear` or `exponential` segment is continuous at its end. +fn for_each_discontinuity(segments: &[Segment], mut emit: impl FnMut(i64)) { + let mut last = None; + for segment in segments { + for time in [segment.start, segment.end] { + let time = i64::try_from(time).unwrap_or(i64::MAX); + if last == Some(time) { + continue; + } + last = Some(time); + if value_before(segments, time).to_bits() != value_at(segments, time).to_bits() { + emit(time); + } + } + } +} + +/// The value the curve approaches from the left of `time`. +fn value_before(segments: &[Segment], time: i64) -> f32 { + let Some(first) = segments.first() else { + return 0.0; + }; + let previous = time.saturating_sub(1); + let Ok(previous) = u64::try_from(previous) else { + return first.start_value; + }; + let Some(segment) = segments + .partition_point(|segment| segment.start <= previous) + .checked_sub(1) + .and_then(|index| segments.get(index)) + else { + return first.start_value; + }; + if previous >= segment.end || segment.shape != Shape::Step { + segment.end_value + } else { + segment.start_value + } +} diff --git a/crates/automation/tests/allocation.rs b/crates/automation/tests/allocation.rs new file mode 100644 index 000000000..06e79fc37 --- /dev/null +++ b/crates/automation/tests/allocation.rs @@ -0,0 +1,52 @@ +//! Gate 5: event generation allocates nothing. + +mod common; + +use automation::{CellEvents, CellState, NodeSpan, SeekMode, Shape}; +use bench_support::alloc::{assert_installed, current_thread_counters, current_thread_delta_since}; +use common::{fader, seg}; + +/// Red if generating the events of 10,000 blocks, seeks and all, allocates or frees. +#[test] +fn ten_thousand_blocks_allocate_nothing() { + assert_installed(); + let table: Vec<_> = (0..300_u64) + .map(|k| { + seg( + k * 700, + k * 700 + 700, + (k % 5) as f32, + ((k * 3) % 7) as f32 + 1.0, + Shape::Linear, + ) + }) + .collect(); + let program = fader(&table); + let cell = program.cell(0).unwrap(); + let mut state = CellState::new(); + // Warm: first touches of thread statics. + let warm = CellEvents::new( + cell, + &mut state, + NodeSpan::new(0, None, 128).unwrap(), + SeekMode::Exact, + 480, + ); + let _ = warm.count(); + + let mark = current_thread_counters(); + let mut events = 0_usize; + for index in 0..10_000_i64 { + let step = (index % 97 == 0).then_some((31, (index * 211) % 200_000)); + let span = NodeSpan::new(128 + index * 128, step, 128).unwrap(); + let mode = if index % 2 == 0 { + SeekMode::Exact + } else { + SeekMode::Ramp + }; + events += CellEvents::new(cell, &mut state, span, mode, 480).count(); + } + 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/automation/tests/build.rs b/crates/automation/tests/build.rs new file mode 100644 index 000000000..0ec604d8b --- /dev/null +++ b/crates/automation/tests/build.rs @@ -0,0 +1,165 @@ +//! The builder: typed refusals, the discontinuity list and the exact byte report. + +mod common; + +use automation::{BuildError, CellDescription, Completion, JumpLength, ProgramBuilder, Shape}; +use bench_support::alloc::{assert_installed, current_thread_counters, current_thread_delta_since}; +use common::{description, seg}; + +fn build(segments: &[automation::Segment]) -> Result { + ProgramBuilder::build(&[description( + segments, + 64, + JumpLength::Block, + Completion::End, + false, + )]) +} + +/// Red if the builder accepts a description that breaks one of the invariants the evaluator and +/// the event rules rely on. +#[test] +fn refusals_are_typed() { + assert_eq!(build(&[]).unwrap_err(), BuildError::NoSegments { cell: 0 }); + assert_eq!( + build(&[seg(5, 5, 1.0, 1.0, Shape::Step)]).unwrap_err(), + BuildError::Segment { + cell: 0, + segment: 0 + } + ); + assert_eq!( + build(&[seg(0, 1 << 53, 1.0, 1.0, Shape::Step)]).unwrap_err(), + BuildError::Segment { + cell: 0, + segment: 0 + } + ); + assert_eq!( + build(&[seg(0, 10, f32::NAN, 1.0, Shape::Linear)]).unwrap_err(), + BuildError::Segment { + cell: 0, + segment: 0 + } + ); + assert_eq!( + build(&[ + seg(0, 10, 1.0, 1.0, Shape::Step), + seg(9, 20, 1.0, 1.0, Shape::Step) + ]) + .unwrap_err(), + BuildError::Order { + cell: 0, + segment: 1 + } + ); + assert_eq!( + build(&[seg(0, 10, 0.0, 1.0, Shape::Exponential)]).unwrap_err(), + BuildError::NonPositiveExponential { + cell: 0, + segment: 0 + } + ); + assert_eq!( + build(&[seg(0, 10, 1.0, -1.0, Shape::Exponential)]).unwrap_err(), + BuildError::NonPositiveExponential { + cell: 0, + segment: 0 + } + ); + let table = [seg(0, 10, 1.0, 1.0, Shape::Step)]; + for (ramp, grid) in [ + (64, 128), + (64, 0), + (128, 64), + (100, 100), + (300, 300), + (10, 32), + ] { + let cell = CellDescription { + grid, + ..description(&table, ramp, JumpLength::Block, Completion::End, false) + }; + assert_eq!( + ProgramBuilder::build(&[cell]).unwrap_err(), + BuildError::Grid { cell: 0 }, + "ramp {ramp} grid {grid}" + ); + } +} + +/// Red if the discontinuity list misses a hold-rule step, invents one at a continuous join, or +/// counts an equal-valued boundary. +#[test] +fn discontinuities_are_the_steps_of_the_hold_rule() { + let table = [ + seg(100, 200, 1.0, 3.0, Shape::Linear), // first start: nothing before it to differ from + seg(200, 300, 3.0, 4.0, Shape::Linear), // continuous join + seg(300, 400, 6.0, 6.0, Shape::Step), // start 6 differs from the held 4 + seg(400, 500, 6.0, 8.0, Shape::Linear), // step end 6 -> 6 held: no jump; join at 6 + seg(600, 700, 8.0, 8.0, Shape::Step), // gap holds 8: start equal, end equal + seg(700, 800, 9.0, 1.0, Shape::Step), // 8 -> 9 at 700; step end 9 -> 1 at 800 + ]; + let program = build(&table).unwrap(); + let cell = program.cell(0).unwrap(); + assert_eq!(cell.discontinuities(), &[300, 700, 800]); +} + +/// Red if `bytes` is not what the build leaves live, or if the build allocates more than once per +/// array. +#[test] +fn bytes_report_equals_the_live_bytes() { + assert_installed(); + let a = [ + seg(0, 100, 1.0, 2.0, Shape::Step), + seg(100, 200, 5.0, 5.0, Shape::Linear), + ]; + let b = [seg(0, 10, 1.0, 1.0, Shape::Step)]; + let cells = [ + description(&a, 64, JumpLength::Block, Completion::End, false), + description(&b, 128, JumpLength::Fixed(128), Completion::After, true), + ]; + let reported = ProgramBuilder::bytes(&cells); + let mark = current_thread_counters(); + let program = ProgramBuilder::build(&cells).unwrap(); + let delta = current_thread_delta_since(mark); + assert_eq!(delta.requested_bytes - delta.released_bytes, reported); + assert_eq!(delta.allocations, 3, "one reservation per array"); + assert_eq!(delta.reallocations, 0); + assert!(reported > 0); + drop(program); +} + +/// Red if the builder accepts two discontinuities closer than the session's 64-sample spacing +/// (#1626 D3), which the per-block event bound rests on, or refuses a pair exactly 64 apart. +#[test] +fn closer_discontinuities_are_refused() { + let table = |second: u64| { + [ + seg(0, 100, 1.0, 1.0, Shape::Step), + seg(100, second, 2.0, 2.0, Shape::Step), + seg(second, 1000, 3.0, 3.0, Shape::Step), + ] + }; + assert_eq!( + build(&table(163)).unwrap_err(), + BuildError::Spacing { cell: 0, time: 163 } + ); + assert_eq!( + build(&table(164)) + .unwrap() + .cell(0) + .unwrap() + .discontinuities(), + &[100, 164] + ); + // A step's own end counts: 2 -> 5 at 150 is 50 after the start at 100. + assert_eq!( + build(&[ + seg(0, 100, 1.0, 1.0, Shape::Step), + seg(100, 150, 2.0, 5.0, Shape::Step), + ]) + .unwrap_err(), + BuildError::Spacing { cell: 0, time: 150 } + ); +} diff --git a/crates/automation/tests/common/mod.rs b/crates/automation/tests/common/mod.rs new file mode 100644 index 000000000..071e600be --- /dev/null +++ b/crates/automation/tests/common/mod.rs @@ -0,0 +1,80 @@ +//! Shared builders for the automation tests. +#![allow(dead_code, unreachable_pub, missing_docs)] + +use automation::{ + CellDescription, CellEvents, CellState, Completion, Event, JumpLength, NodeSpan, Program, + ProgramBuilder, SeekMode, Segment, Shape, +}; + +pub fn seg(start: u64, end: u64, v0: f32, v1: f32, shape: Shape) -> Segment { + Segment { + start, + end, + start_value: v0, + end_value: v1, + shape, + } +} + +pub fn description( + segments: &[Segment], + ramp: u32, + jump: JumpLength, + completion: Completion, + no_restart: bool, +) -> CellDescription<'_> { + CellDescription { + segments, + grid_ramp: ramp, + grid: ramp.max(64), + jump, + completion, + no_restart, + } +} + +/// A one-cell program: a fader-like cell (ramp 64, `End`, block-keyed jump). +pub fn fader(segments: &[Segment]) -> Program { + ProgramBuilder::build(&[description( + segments, + 64, + JumpLength::Block, + Completion::End, + false, + )]) + .expect("valid cell") +} + +pub fn block( + program: &Program, + state: &mut CellState, + span: NodeSpan, + mode: SeekMode, + jump: u32, +) -> Vec { + CellEvents::new(program.cell(0).expect("cell 0"), state, span, mode, jump).collect() +} + +/// Events over `blocks` consecutive blocks of `quantum` samples from node time `start`, each as +/// `(node time, event)`. +pub fn run( + program: &Program, + state: &mut CellState, + start: i64, + quantum: u32, + blocks: u32, + mode: SeekMode, + jump: u32, +) -> Vec<(i64, Event)> { + let mut out = Vec::new(); + for index in 0..blocks { + let first = start + i64::from(index) * i64::from(quantum); + let span = NodeSpan::new(first, None, quantum).expect("span"); + out.extend( + block(program, state, span, mode, jump) + .into_iter() + .map(|event| (first + i64::from(event.offset), event)), + ); + } + out +} diff --git a/crates/automation/tests/corpus.rs b/crates/automation/tests/corpus.rs new file mode 100644 index 000000000..8239a4f07 --- /dev/null +++ b/crates/automation/tests/corpus.rs @@ -0,0 +1,42 @@ +//! The corpus's own sanity: every case produces events. The pins are checked by gate G5 alone. + +use automation::corpus::{CASE_COUNT, run_case}; +use sha2::{Digest, Sha256}; + +fn digest(words: &[u32]) -> [u8; 32] { + let mut hasher = Sha256::new(); + for word in words { + hasher.update(word.to_le_bytes()); + } + hasher.finalize().into() +} + +/// Prints the digests in the pin's source form. Run with `--ignored --nocapture` to regenerate. +#[test] +#[ignore = "pin generator"] +fn print_pins() { + for case in 0..CASE_COUNT { + let words = run_case(case); + println!(" // {}", automation::corpus::CASE_NAMES[case]); + println!(" ["); + for row in digest(&words).chunks(15) { + let bytes: Vec = row.iter().map(|byte| format!("0x{byte:02X}")).collect(); + println!(" {},", bytes.join(", ")); + } + println!(" ],"); + } +} + +/// Red if a case stopped producing events: a digest of nothing agrees everywhere, so gate G5 +/// (`g5_native_digests_match_pins`, the single owner of the pins) would pass vacuously. +#[test] +fn cases_produce_events() { + for case in 0..CASE_COUNT { + let words = run_case(case); + assert!( + words.len() / 5 > 20, + "case {case} has {} events", + words.len() / 5 + ); + } +} diff --git a/crates/automation/tests/evaluate.rs b/crates/automation/tests/evaluate.rs new file mode 100644 index 000000000..b98db00f7 --- /dev/null +++ b/crates/automation/tests/evaluate.rs @@ -0,0 +1,95 @@ +//! Gate 1: the evaluation law and the hold rule. + +mod common; + +use automation::{Shape, value_at}; +use common::seg; + +fn table() -> [automation::Segment; 4] { + [ + seg(100, 200, 1.0, 3.0, Shape::Linear), + seg(300, 400, 5.0, 9.0, Shape::Step), + seg(400, 1400, 20.0, 20_000.0, Shape::Exponential), + seg(2000, 2100, 0.5, 0.25, Shape::Linear), + ] +} + +/// Red if the hold rule sits on the wrong side of a boundary: before the first segment, at a +/// segment's end, in a gap, and after the last. +#[test] +fn hold_rule_before_inside_after_and_between() { + let t = table(); + assert_eq!(value_at(&t, -50), 1.0); + assert_eq!(value_at(&t, 0), 1.0); + assert_eq!(value_at(&t, 99), 1.0); + assert_eq!(value_at(&t, 100), 1.0); + assert_eq!(value_at(&t, 150), 2.0); + assert_eq!( + value_at(&t, 200), + 3.0, + "a segment's end_value holds from its end" + ); + assert_eq!(value_at(&t, 250), 3.0, "and across a gap"); + assert_eq!(value_at(&t, 299), 3.0); + assert_eq!(value_at(&t, 300), 5.0); + assert_eq!( + value_at(&t, 399), + 5.0, + "a step holds start_value on [start, end)" + ); + assert_eq!( + value_at(&t, 400), + 20.0, + "the next segment starts at its start_value" + ); + assert_eq!(value_at(&t, 1400), 20_000.0); + assert_eq!(value_at(&t, 1800), 20_000.0); + assert_eq!(value_at(&t, 2100), 0.25); + assert_eq!(value_at(&t, 1 << 40), 0.25); +} + +/// Red if a step is evaluated as a ramp or its end value is held inside the segment. +#[test] +fn step_end_value_applies_only_from_its_end() { + let t = [seg(10, 20, 2.0, 7.0, Shape::Step)]; + assert_eq!(value_at(&t, 19), 2.0); + assert_eq!(value_at(&t, 20), 7.0); +} + +/// Red if linear is not `v0 + (v1 - v0)·x` at x = 0, 0.5 and 1. +#[test] +fn linear_at_zero_half_and_one() { + let t = [seg(1000, 2000, -6.0, 6.0, Shape::Linear)]; + assert_eq!(value_at(&t, 1000), -6.0); + assert_eq!(value_at(&t, 1500), 0.0); + assert_eq!(value_at(&t, 2000), 6.0); + let x = 250.0_f64 / 1000.0; + assert_eq!(value_at(&t, 1250), (-6.0_f64 + 12.0 * x) as f32); +} + +/// Red if exponential is evaluated in `f32`, with a platform `powf`, or with a different `x`. +#[test] +fn exponential_is_scalar_f64_through_math_pow() { + let t = [seg(0, 48_000, 20.0, 20_000.0, Shape::Exponential)]; + let mut f32_differs = false; + for at in [1_i64, 7, 480, 12_345, 24_000, 33_333, 47_999] { + let x = at as f64 / 48_000.0; + let expected = + (20.0_f32 as f64 * math::pow(20_000.0_f32 as f64 / 20.0_f32 as f64, x)) as f32; + assert_eq!(value_at(&t, at).to_bits(), expected.to_bits(), "t = {at}"); + let narrow = 20.0_f32 * (math::powf(20_000.0_f32 / 20.0_f32, x as f32)); + f32_differs |= narrow.to_bits() != expected.to_bits(); + } + assert!( + f32_differs, + "the f32 formula must differ on at least one input" + ); + assert_eq!(value_at(&t, 0), 20.0); + assert_eq!(value_at(&t, 48_000), 20_000.0); +} + +/// An empty table holds nothing; the builder refuses it, the evaluator does not panic. +#[test] +fn empty_table_is_zero() { + assert_eq!(value_at(&[], 5), 0.0); +} diff --git a/crates/automation/tests/events.rs b/crates/automation/tests/events.rs new file mode 100644 index 000000000..ad3cf00fc --- /dev/null +++ b/crates/automation/tests/events.rs @@ -0,0 +1,706 @@ +//! Gates 2 to 4: the event rules, the per-block bound and quantum and seek invariance. + +mod common; + +use automation::{ + CellEvents, CellState, Completion, EventKind, JumpLength, NodeSpan, ProgramBuilder, SeekMode, + Shape, value_at, +}; +use common::{block, description, fader, run, seg}; + +fn span(start: i64, step: Option<(u32, i64)>, frames: u32) -> NodeSpan { + NodeSpan::new(start, step, frames).unwrap() +} + +/// Red if the grid follows render samples: a grid event at every node-time multiple of 64 and +/// none elsewhere, at quantum 100 (not a multiple of 64) from an off-grid start. +#[test] +fn grid_events_sit_on_node_time_multiples_of_64() { + let program = fader(&[seg(0, 2048, 0.0, 2048.0, Shape::Linear)]); + let mut state = CellState::new(); + let events = run(&program, &mut state, -37, 100, 25, SeekMode::Exact, 480); + let times: Vec = events.iter().map(|(time, _)| *time).collect(); + assert!(times.iter().all(|time| time % 64 == 0), "{times:?}"); + let expected: Vec = (0..=32).map(|k| k * 64).collect(); + assert_eq!(times, expected, "the curve ends at 2048 and then holds"); + for (time, event) in &events { + assert_eq!(event.kind, EventKind::Grid); + assert_eq!(event.ramp, 64); + assert_eq!( + event.value, + value_at(program.cell(0).unwrap().segments(), time + 63) + ); + } +} + +/// Red if a jump snaps to the grid, if a grid ramp that spans a discontinuity eats the jump +/// (change only would then drop it), or if the jump ramp is not the block's length. +#[test] +fn jump_at_off_grid_discontinuity_at_its_exact_offset() { + let program = fader(&[ + seg(0, 100, 1.0, 1.0, Shape::Step), + seg(100, 300, 5.0, 5.0, Shape::Step), + ]); + let mut state = CellState::new(); + let events = block( + &program, + &mut state, + span(0, None, 128), + SeekMode::Exact, + 10, + ); + let shape: Vec<_> = events + .iter() + .map(|e| (e.offset, e.kind, e.ramp, e.value)) + .collect(); + assert_eq!( + shape, + [ + (0, EventKind::Grid, 64, 1.0), + (100, EventKind::Jump, 10, 5.0) + ] + ); + let next = block( + &program, + &mut state, + span(128, None, 128), + SeekMode::Exact, + 10, + ); + assert!( + next.is_empty(), + "the curve is flat after the jump: {next:?}" + ); +} + +/// Red if a completion is off by one: `End` aims at `τ + n - 1`, `After` at `τ + n`. +#[test] +fn end_and_after_completions() { + let table = [ + seg(0, 100, 7.0, 7.0, Shape::Step), + seg(100, 1100, 0.0, 1000.0, Shape::Linear), + ]; + for (completion, aim) in [(Completion::End, 109), (Completion::After, 110)] { + let program = ProgramBuilder::build(&[description( + &table, + 64, + JumpLength::Block, + completion, + false, + )]) + .unwrap(); + let mut state = CellState::new(); + let events = block( + &program, + &mut state, + span(100, None, 28), + SeekMode::Exact, + 10, + ); + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!(events[0].kind, EventKind::Jump); + assert_eq!(events[0].value, value_at(&table, aim)); + } +} + +/// Red if a redundant retarget is emitted: a flat curve emits its first event only. +#[test] +fn change_only() { + let program = fader(&[seg(0, 100_000, 3.0, 3.0, Shape::Linear)]); + let mut state = CellState::new(); + let events = run(&program, &mut state, 0, 128, 50, SeekMode::Exact, 480); + assert_eq!(events.len(), 1); + assert_eq!(events[0].1.value, 3.0); +} + +/// Red if a held event retargets the stage or the grid does not resume after the reported ramp. +#[test] +fn held_inside_a_reported_ramp_and_the_grid_resumes() { + let program = fader(&[seg(0, 100_000, 0.0, 100_000.0, Shape::Linear)]); + let mut state = CellState::new(); + state.hold_until(200); + let events = run(&program, &mut state, 0, 256, 1, SeekMode::Exact, 480); + let held: Vec<(i64, bool)> = events.iter().map(|(t, e)| (*t, e.held)).collect(); + assert_eq!(held, [(0, true), (64, true), (128, true), (192, false)]); + assert_eq!( + state.target(), + Some(value_at(program.cell(0).unwrap().segments(), 255)) + ); + // The held values did not become the target; the first free one did. + let last = events.last().unwrap().1; + assert_eq!(Some(last.value), state.target()); +} + +/// Red if a held event inside a ramp is dropped as unchanged while the stage still remembers an +/// older held value. +#[test] +fn a_held_value_back_at_the_target_still_emits_once() { + // The target 1.0 is set at 0 by a free grid event; the curve then rises and returns while a + // reported ramp is in flight, with no discontinuity. + let table = [ + seg(0, 64, 1.0, 1.0, Shape::Linear), + seg(64, 128, 1.0, 4.0, Shape::Linear), + seg(128, 192, 4.0, 1.0, Shape::Linear), + seg(192, 10_000, 1.0, 1.0, Shape::Linear), + ]; + let program = fader(&table); + let mut state = CellState::new(); + let first = block(&program, &mut state, span(0, None, 1), SeekMode::Exact, 480); + assert_eq!(first.len(), 1); + state.hold_until(1000); + let events = run(&program, &mut state, 1, 255, 1, SeekMode::Exact, 480); + let held: Vec<(i64, f32, bool)> = events.iter().map(|(t, e)| (*t, e.value, e.held)).collect(); + assert_eq!(held.len(), 3, "{held:?}"); + assert!(held.iter().all(|&(_, _, h)| h)); + assert!(held[0].1 > 3.9 && held[1].1 > 1.0); + assert_eq!( + (held[2].0, held[2].1), + (192, 1.0), + "the refresh back at the target emits once" + ); + assert_eq!( + state.target(), + Some(1.0), + "held events never move the target" + ); +} + +/// Red if a `no_restart` jump retargets inside the ramp it would restart, or is lost. +#[test] +fn no_restart_jump_waits_for_the_ramp_to_end() { + let table = [ + seg(0, 100, 1.0, 1.0, Shape::Step), + seg(100, 200, 2.0, 2.0, Shape::Step), + seg(200, 100_000, 3.0, 3.0, Shape::Step), + ]; + let program = ProgramBuilder::build(&[description( + &table, + 128, + JumpLength::Fixed(128), + Completion::End, + true, + )]) + .unwrap(); + let mut state = CellState::new(); + let events = run(&program, &mut state, 0, 100, 5, SeekMode::Exact, 0); + let shape: Vec<_> = events + .iter() + .map(|(t, e)| (*t, e.kind, e.value, e.held)) + .collect(); + // The grid ramp started at 0 runs to 127: the jump at 100 waits to 128 and aims at 2.0 (the + // next discontinuity at 200 bounds its window); the jump at 200 comes after that ramp ends + // at 255, at 256, and aims at 3.0. + assert_eq!( + shape, + [ + (0, EventKind::Grid, 1.0, false), + (128, EventKind::Jump, 2.0, false), + (256, EventKind::Jump, 3.0, false), + ] + ); +} + +fn seek_table() -> automation::Program { + fader(&[ + seg(0, 1000, 0.0, 10.0, Shape::Linear), + seg(1000, 4000, 10.0, 50.0, Shape::Exponential), + ]) +} + +/// Red if a seek step emits nothing, at a mid-block offset or at offset 0, or lands on the wrong +/// value or kind. +#[test] +fn set_at_a_seek_step_for_exact_and_a_ramped_jump_for_ramp() { + let program = seek_table(); + let segments = program.cell(0).unwrap().segments(); + for offset in [0, 37] { + let mut state = CellState::new(); + let a = block( + &program, + &mut state, + span(0, Some((offset, 2500)), 128), + SeekMode::Exact, + 480, + ); + let set = a + .iter() + .find(|e| e.offset == offset) + .expect("an event at the step"); + assert_eq!(set.kind, EventKind::Set, "offset {offset}"); + assert_eq!(set.ramp, 0); + assert_eq!(set.value, value_at(segments, 2500)); + assert_eq!(a.iter().filter(|e| e.offset == offset).count(), 1); + + let mut state = CellState::new(); + let b = block( + &program, + &mut state, + span(0, Some((offset, 2500)), 128), + SeekMode::Ramp, + 480, + ); + let jump = b + .iter() + .find(|e| e.offset == offset) + .expect("an event at the step"); + assert_eq!(jump.kind, EventKind::Jump, "offset {offset}"); + assert_eq!(jump.ramp, 480); + assert_eq!(jump.value, value_at(segments, 2500 + 479)); + } +} + +/// Red if `Set` is dropped by change only: a seek to the value the target already holds still +/// emits. +#[test] +fn set_always_emits() { + let program = fader(&[seg(0, 100_000, 3.0, 3.0, Shape::Linear)]); + let mut state = CellState::new(); + let _ = block(&program, &mut state, span(0, None, 64), SeekMode::Exact, 0); + let again = block( + &program, + &mut state, + span(64, Some((10, 5000)), 64), + SeekMode::Exact, + 0, + ); + assert_eq!(again.iter().filter(|e| e.kind == EventKind::Set).count(), 1); +} + +/// Red if a jump uses a stored length instead of the word the block passes. +#[test] +fn a_block_keyed_jump_ramps_over_the_blocks_length() { + let program = fader(&[ + seg(0, 100, 1.0, 1.0, Shape::Step), + seg(100, 400, 5.0, 5.0, Shape::Step), + seg(400, 500, 9.0, 9.0, Shape::Step), + ]); + let mut state = CellState::new(); + let first = block( + &program, + &mut state, + span(64, None, 100), + SeekMode::Exact, + 100, + ); + let second = block( + &program, + &mut state, + span(300, None, 200), + SeekMode::Exact, + 300, + ); + let jump = |events: &[automation::Event]| { + events + .iter() + .find(|e| e.kind == EventKind::Jump) + .map(|e| e.ramp) + }; + assert_eq!(jump(&first), Some(100)); + assert_eq!(jump(&second), Some(300)); +} + +/// A `Fixed` key ignores the block's word. +#[test] +fn a_fixed_key_ignores_the_blocks_length() { + let table = [ + seg(0, 100, 1.0, 1.0, Shape::Step), + seg(100, 400, 5.0, 5.0, Shape::Step), + ]; + let program = ProgramBuilder::build(&[description( + &table, + 64, + JumpLength::Fixed(64), + Completion::After, + false, + )]) + .unwrap(); + let mut state = CellState::new(); + let events = block( + &program, + &mut state, + span(64, None, 100), + SeekMode::Exact, + 999, + ); + assert_eq!( + events + .iter() + .find(|e| e.kind == EventKind::Jump) + .map(|e| e.ramp), + Some(64) + ); +} + +/// Red if a span with a step outside the block is accepted. +#[test] +fn a_step_outside_the_block_is_refused() { + assert!(NodeSpan::new(0, Some((128, 5)), 128).is_err()); + assert!(NodeSpan::new(0, Some((127, 5)), 128).is_ok()); +} + +fn adversarial() -> automation::Program { + // A discontinuity every 64 samples, every segment moving. + let table: Vec<_> = (0..400_u64) + .map(|k| { + let (a, b) = if k % 2 == 0 { (0.0, 1.0) } else { (3.0, 2.0) }; + seg(k * 64 + 32, k * 64 + 96, a, b, Shape::Linear) + }) + .collect(); + fader(&table) +} + +fn no_restart_adversarial() -> automation::Program { + // The same discontinuity every 64 samples, on a delay-like cell whose jumps wait for its ramp. + let table: Vec<_> = (0..400_u64) + .map(|k| { + let (a, b) = if k % 2 == 0 { (0.0, 1.0) } else { (3.0, 2.0) }; + seg(k * 64 + 32, k * 64 + 96, a, b, Shape::Linear) + }) + .collect(); + ProgramBuilder::build(&[description( + &table, + 32, + JumpLength::Fixed(0), + Completion::End, + true, + )]) + .unwrap() +} + +/// The stated per-block bound: `2⌈q/64⌉ + 3`, plus one for a `no_restart` cell. +fn stated_bound(quantum: u32, no_restart: bool) -> usize { + 2 * quantum.div_ceil(64) as usize + 3 + usize::from(no_restart) +} + +/// Red if a pathological table makes a block's work grow past the stated bound, or if +/// `max_events` (the capacity a successor sizes its buffer by) differs from it: a discontinuity +/// every 64 samples, seeks mid-block, and for the `no_restart` cell, caller ramps that defer jumps +/// across blocks. +#[test] +fn events_per_block_are_bounded() { + for (program, no_restart) in [(adversarial(), false), (no_restart_adversarial(), true)] { + let cell = program.cell(0).unwrap(); + for quantum in [1_u32, 63, 64, 100, 128, 1024] { + let bound = stated_bound(quantum, no_restart); + assert_eq!(cell.max_events(quantum) as usize, bound); + let mut state = CellState::new(); + let mut most = 0; + for index in 0..(20_000 / quantum).min(2_000) { + let first = i64::from(index) * i64::from(quantum); + // A seek in every fifth block, at a mid-block offset; a caller ramp in every + // seventh. + let step = (index % 5 == 2).then_some((quantum / 3, (first * 7) % 24_000)); + if index % 7 == 3 { + state.hold_until(first + 70); + } + let events = CellEvents::new( + cell, + &mut state, + span(first, step, quantum), + SeekMode::Exact, + 480, + ) + .collect::>(); + assert!(events.windows(2).all(|w| w[0].offset < w[1].offset)); + assert!(events.iter().all(|e| e.offset < quantum)); + assert!( + events.len() <= bound, + "no_restart {no_restart}, q {quantum}: {} > {bound}", + events.len() + ); + most = most.max(events.len()); + } + // The tables stress the bound: a block holds more events than its grid points alone. + if quantum >= 64 { + assert!( + most > quantum.div_ceil(64) as usize, + "no_restart {no_restart}, q {quantum}: most {most}" + ); + } + } + } +} + +/// Jumps at 70 and 134, then a step to a ramp at 1010, on a `no_restart` cell with jump ramp 0. +fn deferral_table() -> automation::Program { + let table = [ + seg(0, 70, 1.0, 1.0, Shape::Step), + seg(70, 134, 2.0, 2.0, Shape::Step), + seg(134, 1010, 3.0, 3.0, Shape::Step), + seg(1010, 2000, 4.0, 5.0, Shape::Linear), + ]; + ProgramBuilder::build(&[description( + &table, + 32, + JumpLength::Fixed(0), + Completion::End, + true, + )]) + .unwrap() +} + +/// The verifier's case for attempt 1's `+ 3` on a `no_restart` cell: a jump deferred from the +/// previous block, two discontinuities and a seek in one 64-sample block. Red if the bound or +/// `max_events` is lower than the six events this block yields (so `+ 4` is reached, not slack). +#[test] +fn a_carried_deferral_reaches_the_no_restart_bound() { + let program = deferral_table(); + let cell = program.cell(0).unwrap(); + let mut state = CellState::new(); + state.hold_until(130); + let _ = block(&program, &mut state, span(64, None, 64), SeekMode::Exact, 0); + let events = block( + &program, + &mut state, + span(128, Some((23, 1000)), 64), + SeekMode::Exact, + 0, + ); + let shape: Vec<_> = events.iter().map(|e| (e.offset, e.kind)).collect(); + assert_eq!( + shape, + [ + (0, EventKind::Grid), + (3, EventKind::Jump), // the jump at 70, deferred to 131 + (6, EventKind::Jump), // the discontinuity at 134 + (23, EventKind::Set), // the seek to 1000 + (33, EventKind::Jump), // the discontinuity at 1010 + (47, EventKind::Grid), // 1024 + ] + ); + assert_eq!(events.len(), stated_bound(64, true)); + assert_eq!(cell.max_events(64) as usize, events.len()); +} + +/// Red if a deferral fires inside a caller ramp reported between blocks (attempt 2 checked the lane +/// only for a fresh discontinuity): the jump at 70, deferred to 131, meets a caller ramp that runs +/// to 200, so it and the discontinuity at 134 wait for the lane and fire once at 201, the ramp's +/// last sample plus one, aimed at the curve there. +#[test] +fn a_deferral_waits_for_a_caller_ramp_reported_after_it() { + let program = deferral_table(); + let mut state = CellState::new(); + state.hold_until(130); + let _ = block(&program, &mut state, span(64, None, 64), SeekMode::Exact, 0); + state.hold_until(200); + let second = block( + &program, + &mut state, + span(128, None, 64), + SeekMode::Exact, + 0, + ); + assert!( + second.iter().all(|e| e.kind == EventKind::Grid && e.held), + "{second:?}" + ); + let third = block( + &program, + &mut state, + span(192, None, 64), + SeekMode::Exact, + 0, + ); + let jumps: Vec<_> = third + .iter() + .filter(|e| e.kind == EventKind::Jump) + .map(|e| (192 + i64::from(e.offset), e.value)) + .collect(); + assert_eq!(jumps, [(201, 3.0)], "{third:?}"); + + // The same deferral carried behind a forward span is due at the run's first sample (500), + // where the caller's ramp still runs to 600: it waits for 601. + let mut state = CellState::new(); + state.hold_until(130); + let _ = block(&program, &mut state, span(64, None, 64), SeekMode::Exact, 0); + state.hold_until(600); + let skipped = block( + &program, + &mut state, + span(500, None, 64), + SeekMode::Exact, + 0, + ); + assert!( + skipped.iter().all(|e| e.kind != EventKind::Jump), + "{skipped:?}" + ); + let _ = block( + &program, + &mut state, + span(564, None, 32), + SeekMode::Exact, + 0, + ); + let due = block( + &program, + &mut state, + span(596, None, 64), + SeekMode::Exact, + 0, + ); + let jumps: Vec<_> = due + .iter() + .filter(|e| e.kind == EventKind::Jump) + .map(|e| (596 + i64::from(e.offset), e.value)) + .collect(); + assert_eq!(jumps, [(601, 3.0)], "{due:?}"); +} + +/// Red if a deferral the caller skipped past (node time moved forward with no seek step) gives an +/// offset outside the block (attempt 1 wrapped a negative distance to `4294966927`), or is lost: +/// it is due at the span's first sample, aimed at the curve there. +#[test] +fn a_deferral_behind_a_forward_span_is_due_at_its_start() { + let program = deferral_table(); + let mut state = CellState::new(); + state.hold_until(130); + let first = block(&program, &mut state, span(64, None, 64), SeekMode::Exact, 0); + assert!(first.iter().all(|e| e.kind == EventKind::Grid && e.held)); + let events = block( + &program, + &mut state, + span(500, None, 64), + SeekMode::Exact, + 0, + ); + assert!(events.iter().all(|e| e.offset < 64), "{events:?}"); + let shape: Vec<_> = events.iter().map(|e| (e.offset, e.kind, e.value)).collect(); + assert_eq!(shape, [(0, EventKind::Jump, 3.0)]); +} + +/// Red if change only drops a real jump because the grid ramp in flight aims at the same value: +/// the grid ramp from 0 aims at `value(31) = 32`, the curve jumps from its left limit 33 to +/// `value(32) = 32`, and the ramp (completing at 64) has not reached 32 at sample 32. +#[test] +fn a_jump_to_the_in_flight_target_still_lands_at_its_time() { + let table = [ + seg(0, 32, 1.0, 33.0, Shape::Linear), + seg(32, 500, 32.0, 100.0, Shape::Linear), + ]; + let program = ProgramBuilder::build(&[description( + &table, + 64, + JumpLength::Fixed(0), + Completion::After, + false, + )]) + .unwrap(); + assert_eq!(program.cell(0).unwrap().discontinuities(), &[32]); + let mut state = CellState::new(); + let events = block(&program, &mut state, span(0, None, 128), SeekMode::Exact, 0); + let shape: Vec<_> = events + .iter() + .map(|e| (e.offset, e.kind, e.ramp, e.value)) + .collect(); + assert_eq!(shape[0], (0, EventKind::Grid, 64, 32.0)); + assert_eq!(shape[1], (32, EventKind::Jump, 0, 32.0)); +} + +fn digest_table() -> automation::Program { + fader(&[ + seg(100, 2000, 1.0, 9.0, Shape::Linear), + seg(2300, 2900, 2.0, 2.0, Shape::Step), + seg(2900, 9000, 4.0, 400.0, Shape::Exponential), + seg(9000, 9100, 3.0, 5.0, Shape::Step), + ]) +} + +/// `(node time, kind, ramp, value bits, held)` of every event. +fn trace( + program: &automation::Program, + state: &mut CellState, + start: i64, + quantum: u32, + blocks: u32, + mode: SeekMode, +) -> Vec<(i64, EventKind, u32, u32, bool)> { + run(program, state, start, quantum, blocks, mode, 480) + .into_iter() + .map(|(t, e)| (t, e.kind, e.ramp, e.value.to_bits(), e.held)) + .collect() +} + +/// Red if event values depend on the quantum: the same table at quantum 128 and at quantum 100 +/// (blocks that straddle the grid differently) gives the same events. The blocks hold no seek, so +/// the seek mode does not enter. +#[test] +fn quantum_invariance() { + let program = digest_table(); + let a = trace( + &program, + &mut CellState::new(), + -128, + 128, + 90, + SeekMode::Exact, + ); + let b = trace( + &program, + &mut CellState::new(), + -128, + 100, + 116, + SeekMode::Exact, + ); + let end = 11_000; + let a: Vec<_> = a.into_iter().filter(|e| e.0 < end).collect(); + let b: Vec<_> = b.into_iter().filter(|e| e.0 < end).collect(); + assert_eq!(a, b); + assert!(a.len() > 100); +} + +/// Red if the events after a seek depend on the render sample the seek landed at, or on what +/// came before it (blocker B1). +#[test] +fn seek_invariance() { + let program = digest_table(); + let target = 2750_i64; + for mode in [SeekMode::Exact, SeekMode::Ramp] { + let mut results = Vec::new(); + // The same timeline sample reached at two render positions, after different histories. + for (quantum, history, offset) in [(128_u32, 7_u32, 37_u32), (100, 11, 91), (128, 3, 0)] { + let mut state = CellState::new(); + let _ = trace(&program, &mut state, 0, quantum, history, mode); + let first = i64::from(quantum) * i64::from(history); + let step = Some((offset, target)); + let landing = CellEvents::new( + program.cell(0).unwrap(), + &mut state, + span(first, step, quantum), + mode, + 480, + ) + .collect::>(); + let landed_at = landing.iter().find(|e| e.offset == offset).unwrap(); + assert!(matches!(landed_at.kind, EventKind::Set | EventKind::Jump)); + // The rest of the landing block and the following blocks, in node time. + let mut after: Vec<(i64, EventKind, u32, u32, bool)> = landing + .iter() + .filter(|e| e.offset > offset) + .map(|e| { + ( + target + i64::from(e.offset - offset), + e.kind, + e.ramp, + e.value.to_bits(), + e.held, + ) + }) + .collect(); + let landed_block_end = target + i64::from(quantum - offset); + after.extend(trace( + &program, + &mut state, + landed_block_end, + quantum, + 40, + mode, + )); + after.retain(|e| e.0 < target + 4000); + results.push((landed_at.value.to_bits(), landed_at.ramp, after)); + } + assert!(results[0].2.len() > 20); + assert!(results.windows(2).all(|w| w[0] == w[1]), "{mode:?}"); + } +} diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 16934a6c7..5659b61d7 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -11552,18 +11552,17 @@ mod tests { } /// The drain applies a strip's fader cells before its mute cells (#1312 D4), whatever order the - /// writes came in: they share one gain ramp, and a fader move after a mute would cut the - /// mute's ramp short. The oracle applies the same two values directly in D4's order; the - /// reverse order is shown to differ, so the comparison can tell them apart. + /// writes came in: they share one gain ramp, and a fader move after an unmute would cut the + /// unmute's ramp short. (Since #1638 a fader move on a muted lane only remembers the gain, so + /// the case starts muted and unmutes: a mute write would leave the two orders alike.) The + /// oracle applies the same two values directly in D4's order; the reverse order is shown to + /// differ, so the comparison can tell them apart. #[test] fn the_drain_applies_the_fader_before_the_mute_whatever_the_write_order() { + let mut muted = BuiltinParameters::default(); + muted.left.muted = true; let new_bank = || { - BuiltinFaderBank::new( - Backend::Simd4, - BankWidth::Four, - vec![BuiltinParameters::default(); 4], - ) - .expect("bank") + BuiltinFaderBank::new(Backend::Simd4, BankWidth::Four, vec![muted; 4]).expect("bank") }; let words = |bank: &BuiltinFaderBank| builtins::test_support::fader_bank_lane_words(bank, 0); @@ -11574,11 +11573,11 @@ mod tests { .set_fader_db(0, BuiltinLaneSelector::Left, fader_db, fader_ramp) .unwrap(); canonical - .set_mute(0, BuiltinLaneSelector::Left, true, mute_ramp) + .set_mute(0, BuiltinLaneSelector::Left, false, mute_ramp) .unwrap(); let mut reversed = new_bank(); reversed - .set_mute(0, BuiltinLaneSelector::Left, true, mute_ramp) + .set_mute(0, BuiltinLaneSelector::Left, false, mute_ramp) .unwrap(); reversed .set_fader_db(0, BuiltinLaneSelector::Left, fader_db, fader_ramp) @@ -11604,7 +11603,7 @@ mod tests { let mut gate = TestOnlyGate::new(); let mute = TrackFaderRecord::Mute { lanes: BuiltinLaneSelector::Left, - muted: true, + muted: false, smoothing_samples: mute_ramp, }; let fader = TrackFaderRecord::FaderDb { diff --git a/crates/builtins/src/lib.rs b/crates/builtins/src/lib.rs index 49a709dfd..be7b4300c 100644 --- a/crates/builtins/src/lib.rs +++ b/crates/builtins/src/lib.rs @@ -151,6 +151,13 @@ pub struct BuiltinProcessReport { /// Right-channel lane-blocks whose output failed the boundary check; see /// [`BuiltinProcessReport::recovered_left_state`]. pub recovered_right_state: u64, + /// `1` when a timed fader block stopped pulling operations early because its + /// [`FaderOperationSource`] broke the strictly rising offset order (issue #1638), else `0`. + /// + /// The operation at the offending offset stays with the source and the rest of the block + /// renders with no further operation. An offset at or past the block's length is the normal + /// end of a block's operations and is not counted. + pub fader_operations_stopped: u64, } /// A shape-validated dual-mono render block. @@ -2004,6 +2011,7 @@ impl InputStage { sanitized_output: 0, recovered_left_state: recovered[0], recovered_right_state: recovered[1], + fader_operations_stopped: 0, } } // REALTIME_POLICY_END @@ -2148,6 +2156,7 @@ impl InputStage { sanitized_output: 0, recovered_left_state: recovered, recovered_right_state: recovered, + fader_operations_stopped: 0, } } // REALTIME_POLICY_END @@ -2865,7 +2874,8 @@ struct InputSectionState { /// /// Plain data, fixed size, no heap: per channel the gain in use, the ramp's target and per-sample /// step, the authoritative countdown, the gain the fader holds independent of mute, and the mute -/// flag. In-memory state handed across a plan replacement, never persisted (AGENTS.md, R6b). +/// flag, and whether a mute ramp holds the gain (#1638 D2). In-memory state handed across a plan +/// replacement, never persisted (AGENTS.md, R6b). #[derive(Clone, Copy, Debug)] pub struct FaderLaneState { channels: [FaderChannelState; 2], @@ -2885,6 +2895,8 @@ struct FaderChannelState { fader_gain: f32, /// Whether the lane is muted. muted: bool, + /// Whether a mute or unmute ramp is in flight (issue #1638 D2). + mute_ramp: bool, } /// One matrix bank lane's whole state: what a plan swap hands from the predecessor's lane to the @@ -2987,6 +2999,12 @@ pub(crate) struct FaderRampStage { muted: [[bool; MAX_BANK_LANES]; 2], /// Frames left in each lane's ramp, `[channel][lane]`. remaining: [[u32; MAX_BANK_LANES]; 2], + /// Whether a mute or unmute ramp is in flight on each lane, `[channel][lane]` (issue #1638 + /// D2). Set by a mute change with a nonzero window; cleared in the bookkeeping step that ends + /// the lane's countdown, by a zero-window mute change, by a live gain retarget and by + /// [`Self::reset`]. While it is set, or while the lane is muted, the lane's gain is *held*: a + /// timed gain operation only remembers the gain. + mute_ramp: [[bool; MAX_BANK_LANES]; 2], } /// Largest ramp countdown that is exact in `f32`. @@ -3032,6 +3050,7 @@ impl FaderRampStage { fader_gain, muted, remaining: [[0; MAX_BANK_LANES]; 2], + mute_ramp: [[false; MAX_BANK_LANES]; 2], } } @@ -3076,10 +3095,12 @@ impl FaderRampStage { } } - /// Retargets one lane's fader gain on the channels `channels` covers. + /// The live path's fader move on the channels `channels` covers (issue #1638, README F22). /// - /// A muted lane keeps its `0.0` target: the new gain is remembered and takes effect when the - /// lane is unmuted, exactly as a physical console's fader does. + /// The gain is always remembered. While the lane is muted -- settled, or ramping toward `0.0` + /// -- that is all a live move does: the lane keeps its mute ramp and its `0.0` target, and the + /// gain takes effect when the lane is unmuted, as a physical console's fader does. On an + /// unmuted lane, including one in an unmute ramp, the move retargets and ends any mute ramp. fn set_fader_gain( &mut self, lane: usize, @@ -3088,15 +3109,22 @@ impl FaderRampStage { smoothing_samples: u32, ) { for channel in 0..2 { - if !channels.covers(channel) { - continue; + if channels.covers(channel) { + self.live_gain(lane, channel, gain, smoothing_samples); } - self.fader_gain[channel][lane] = gain; - let target = if self.muted[channel][lane] { 0.0 } else { gain }; - self.retarget(lane, channel, target, smoothing_samples); } } + /// One channel of [`Self::set_fader_gain`]. + fn live_gain(&mut self, lane: usize, channel: usize, gain: f32, smoothing_samples: u32) { + self.fader_gain[channel][lane] = gain; + if self.muted[channel][lane] { + return; + } + self.retarget(lane, channel, gain, smoothing_samples); + self.mute_ramp[channel][lane] = false; + } + /// Sets or clears one lane's mute on the channels `channels` covers, as a retarget. fn set_mute( &mut self, @@ -3106,17 +3134,54 @@ impl FaderRampStage { smoothing_samples: u32, ) { for channel in 0..2 { - if !channels.covers(channel) { - continue; + if channels.covers(channel) { + self.mute_channel(lane, channel, muted, smoothing_samples); } - self.muted[channel][lane] = muted; - let target = if muted { - 0.0 - } else { - self.fader_gain[channel][lane] - }; - self.retarget(lane, channel, target, smoothing_samples); - self.sync_mute(channel); + } + } + + /// One channel of [`Self::set_mute`]. A nonzero window starts a mute ramp (D2). + fn mute_channel(&mut self, lane: usize, channel: usize, muted: bool, smoothing_samples: u32) { + self.muted[channel][lane] = muted; + let target = if muted { + 0.0 + } else { + self.fader_gain[channel][lane] + }; + self.retarget(lane, channel, target, smoothing_samples); + self.mute_ramp[channel][lane] = smoothing_samples != 0; + self.sync_mute(channel); + } + + /// Whether one lane's gain is held: muted, or in a mute or unmute ramp (issue #1638 D2). + const fn gain_held(&self, lane: usize, channel: usize) -> bool { + self.muted[channel][lane] || self.mute_ramp[channel][lane] + } + + /// Frames left in one lane's ramp: `0` when the lane is settled. + const fn ramp_remaining(&self, lane: usize, channel: usize) -> u32 { + self.remaining[channel][lane] + } + + /// Applies one timed operation to one lane and channel (issue #1638 D1 and D2). + fn apply_operation(&mut self, lane: usize, channel: usize, operation: FaderOperation) { + match operation { + FaderOperation::RetargetGain { gain, samples } => { + self.timed_gain(lane, channel, gain, samples); + } + FaderOperation::SetGain { gain } => self.timed_gain(lane, channel, gain, 0), + FaderOperation::SetMute { muted, samples } => { + self.mute_channel(lane, channel, muted, samples); + } + FaderOperation::RememberGain { gain } => self.fader_gain[channel][lane] = gain, + } + } + + /// A timed gain move: a retarget while the lane's gain is free, a remember while it is held. + fn timed_gain(&mut self, lane: usize, channel: usize, gain: f32, samples: u32) { + self.fader_gain[channel][lane] = gain; + if !self.gain_held(lane, channel) { + self.retarget(lane, channel, gain, samples); } } @@ -3175,6 +3240,8 @@ impl FaderRampStage { if self.remaining[channel][lane] == 0 { current[lane] = target[lane]; steps[lane] = 0.0; + // D2: a mute ramp ends in the same step that assigns its target. + self.mute_ramp[channel][lane] = false; } } self.ramp[channel].current = lane_words::(¤t); @@ -3190,6 +3257,62 @@ impl FaderRampStage { ); } } + + /// Renders frames `[start, end)` of both channels as one block (issue #1638 D3). + fn process_range(&mut self, left: &mut [f32], right: &mut [f32], start: usize, end: usize) { + let (from, to) = (start * L::WIDTH, end * L::WIDTH); + self.process_plane(0, &mut left[from..to], end - start); + self.process_plane(1, &mut right[from..to], end - start); + } + + /// Renders one block with timed operations inside it (issue #1638 D3). + /// + /// The block is cut at each offset the source gives. Each piece runs [`Self::process_plane`] + /// exactly as a block of its length would, recomputing the countdown words from the `u32` + /// countdowns, and the operations at the next offset are then applied in D4's order. So the + /// block renders the bits of the same blocks split at the offsets, and a source with no + /// operation in the block gives one piece: [`Self::process`]. + /// + /// An offset at or past `frames` is not pulled: it stays with the source for a later block. + /// Offsets must rise strictly; a source that repeats or lowers one breaks its contract and the + /// stage stops pulling for this block (the operation stays with the source), which bounds the + /// loop at `frames` pieces. Returns `true` when pulling stopped that way, so the caller can + /// report it ([`BuiltinProcessReport::fader_operations_stopped`]). + fn process_with( + &mut self, + left: &mut [f32], + right: &mut [f32], + frames: usize, + lanes: usize, + operations: &mut S, + ) -> bool { + let mut position = 0_usize; + let mut applied: Option = None; + let mut stopped = false; + while let Some(offset) = operations.next_offset() { + if applied.is_some_and(|last| offset <= last) { + debug_assert!(false, "fader operation offsets must rise strictly"); + stopped = true; + break; + } + let at = offset as usize; + if at >= frames { + break; + } + if at > position { + self.process_range(left, right, position, at); + position = at; + } + let mut sink = FaderOperationSink::new(lanes.min(L::WIDTH)); + operations.apply(offset, &mut sink); + sink.apply_to(self); + applied = Some(offset); + } + if position < frames { + self.process_range(left, right, position, frames); + } + stopped + } // REALTIME_POLICY_END // REALTIME_POLICY_BEGIN @@ -3204,6 +3327,7 @@ impl FaderRampStage { remaining: self.remaining[channel][lane], fader_gain: self.fader_gain[channel][lane], muted: self.muted[channel][lane], + mute_ramp: self.mute_ramp[channel][lane], }), } } @@ -3225,6 +3349,7 @@ impl FaderRampStage { self.remaining[channel][lane] = carried.remaining; self.fader_gain[channel][lane] = carried.fader_gain; self.muted[channel][lane] = carried.muted; + self.mute_ramp[channel][lane] = carried.mute_ramp; self.sync_mute(channel); } } @@ -3238,6 +3363,7 @@ impl FaderRampStage { self.ramp[channel].remaining = L::zero(); } self.remaining = [[0; MAX_BANK_LANES]; 2]; + self.mute_ramp = [[false; MAX_BANK_LANES]; 2]; } } @@ -4429,6 +4555,101 @@ impl BuiltinFaderBank { BuiltinProcessReport::default() } + // REALTIME_POLICY_BEGIN + /// Renders one AoSoA block with timed operations inside it (issue #1638 D3). + /// + /// Bit for bit what [`Self::process`] renders over the same block split at each operation's + /// offset, with the operations applied by [`Self::apply_fader_operation`] at each split. A + /// source with no operation in the block renders exactly what [`Self::process`] does. + pub fn process_with( + &mut self, + left: &mut [f32], + right: &mut [f32], + frames: u32, + operations: &mut S, + ) -> BuiltinProcessReport { + let frames = frames as usize; + debug_assert_eq!(left.len(), frames * self.width.lanes() as usize); + debug_assert_eq!(right.len(), frames * self.width.lanes() as usize); + let lanes = self.members; + let stopped = per_width!(FaderStageKernel(stage) in &mut self.stage => { + stage.process_with(left, right, frames, lanes, operations) + }); + BuiltinProcessReport { + fader_operations_stopped: u64::from(stopped), + ..BuiltinProcessReport::default() + } + } + // REALTIME_POLICY_END + + /// Applies one timed operation now, at the block boundary (issue #1638 D1 and D2). + /// + /// # Errors + /// + /// [`FaderOperationError::Lane`] when `lane` is not a populated member, and + /// [`FaderOperationError::Gain`] when the gain is not finite or is negative. + pub fn apply_fader_operation( + &mut self, + lane: usize, + channels: BuiltinLaneSelector, + operation: FaderOperation, + ) -> Result<(), FaderOperationError> { + if lane >= self.members { + return Err(FaderOperationError::Lane); + } + let operation = checked_operation(operation)?; + per_width!(FaderStageKernel(stage) in &mut self.stage => { + for channel in 0..2 { + if channels.covers(channel) { + stage.apply_operation(lane, channel, operation); + } + } + }); + Ok(()) + } + + /// Whether a member lane's gain is held on `channel` (`0` left, `1` right): muted, or in a + /// mute or unmute ramp (issue #1638 D2). `None` for a padding lane or a channel above `1`. + #[must_use] + pub fn gain_held(&self, lane: usize, channel: usize) -> Option { + if lane >= self.members || channel > 1 { + return None; + } + Some(per_width!(FaderStageKernel(stage) in &self.stage => stage.gain_held(lane, channel))) + } + + /// Frames left in a member lane's ramp on `channel`; `0` when settled. `None` for a padding + /// lane or a channel above `1`. + #[must_use] + pub fn ramp_remaining(&self, lane: usize, channel: usize) -> Option { + if lane >= self.members || channel > 1 { + return None; + } + Some( + per_width!(FaderStageKernel(stage) in &self.stage => stage.ramp_remaining(lane, channel)), + ) + } + + /// [`Self::try_process_settled_with_matrix`] for a block that may carry timed operations + /// (issue #1638 D5): declines, touching nothing, when the source has an operation inside the + /// block, so the caller renders it with [`Self::process_with`]. + pub fn try_process_settled_with_matrix_and_operations( + &mut self, + matrix: &mut BuiltinMatrixBank, + left: &mut [f32], + right: &mut [f32], + frames: u32, + operations: &mut S, + ) -> bool { + if operations + .next_offset() + .is_some_and(|offset| offset < frames) + { + return false; + } + self.try_process_settled_with_matrix(matrix, left, right, frames) + } + /// Runs the settled fader and matrix stages in one traversal when their shapes and ramps /// agree. Returns false without touching either stage when a ramp is still active. pub fn try_process_settled_with_matrix( @@ -4696,6 +4917,161 @@ impl BuiltinLaneSelector { } } +/// One timed fader or mute operation on one lane (issue #1638 D1). +/// +/// Gains are linear coefficients: the caller converts decibels with [`checked_fader_gain`]. +/// Windows are in sample updates; a window of `0` is an exact set. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum FaderOperation { + /// Move the fader toward `gain` over `samples`. While the lane's gain is held (muted, or in + /// a mute or unmute ramp) this only remembers the gain, as [`Self::RememberGain`] does. + RetargetGain { gain: f32, samples: u32 }, + /// [`Self::RetargetGain`] with a window of `0`: an exact set, or a remember while held. + SetGain { gain: f32 }, + /// Mute or unmute over `samples`, as the live mute does. A nonzero window holds the lane's + /// gain until the ramp ends. + SetMute { muted: bool, samples: u32 }, + /// Update the remembered fader gain only, with no retarget. + RememberGain { gain: f32 }, +} + +impl FaderOperation { + /// Whether this is a gain operation (applied before a mute operation at one offset, D4). + const fn is_gain(self) -> bool { + !matches!(self, Self::SetMute { .. }) + } + + /// The gain this operation carries, if any. + const fn gain(self) -> Option { + match self { + Self::RetargetGain { gain, .. } + | Self::SetGain { gain } + | Self::RememberGain { gain } => Some(gain), + Self::SetMute { .. } => None, + } + } +} + +/// Why a timed fader operation was refused. A refused operation changes nothing. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FaderOperationError { + /// The lane is a padding lane or past the bank. + Lane, + /// The gain is not finite or is negative. + Gain, + /// The lane and channel already carry an operation of the same kind at this offset. + Duplicate, +} + +/// Checks one operation's gain: a finite, nonnegative linear coefficient. +fn checked_operation(operation: FaderOperation) -> Result { + match operation.gain() { + Some(gain) if !(gain.is_finite() && gain >= 0.0) => Err(FaderOperationError::Gain), + _ => Ok(operation), + } +} + +/// The operations a source applies at one offset (issue #1638 D3 and D4). +/// +/// Fixed size, on the render stack, never allocated. Each lane and channel takes at most one gain +/// operation and one mute operation per offset; whatever order they are pushed in, the stage +/// applies every gain operation before every mute operation, left before right, as the live drain +/// does. So an unmute and a fader move at one offset unmute toward the new gain, and a mute and a +/// fader move at one offset end muted. +#[derive(Debug)] +pub struct FaderOperationSink { + lanes: usize, + gain: [[Option; MAX_BANK_LANES]; 2], + mute: [[Option; MAX_BANK_LANES]; 2], +} + +impl FaderOperationSink { + // REALTIME_POLICY_BEGIN + const fn new(lanes: usize) -> Self { + Self { + lanes, + gain: [[None; MAX_BANK_LANES]; 2], + mute: [[None; MAX_BANK_LANES]; 2], + } + } + + /// Queues one operation on `lane` for the channels `channels` covers. + /// + /// # Errors + /// + /// [`FaderOperationError::Lane`] for a padding lane or one past the bank, + /// [`FaderOperationError::Gain`] for a gain that is not finite or is negative, and + /// [`FaderOperationError::Duplicate`] when a covered channel already has an operation of the + /// same kind at this offset. A refusal queues nothing, on either channel. + pub fn push( + &mut self, + lane: usize, + channels: BuiltinLaneSelector, + operation: FaderOperation, + ) -> Result<(), FaderOperationError> { + if lane >= self.lanes { + return Err(FaderOperationError::Lane); + } + let operation = checked_operation(operation)?; + let slots = if operation.is_gain() { + &mut self.gain + } else { + &mut self.mute + }; + for (channel, slot) in slots.iter().enumerate() { + if channels.covers(channel) && slot[lane].is_some() { + return Err(FaderOperationError::Duplicate); + } + } + for (channel, slot) in slots.iter_mut().enumerate() { + if channels.covers(channel) { + slot[lane] = Some(operation); + } + } + Ok(()) + } + + /// Applies the queued operations in D4's order: gains, then mutes, each left before right. + fn apply_to(&self, stage: &mut FaderRampStage) { + for slots in [&self.gain, &self.mute] { + for (channel, slot) in slots.iter().enumerate() { + for (lane, operation) in slot.iter().enumerate().take(self.lanes) { + if let Some(operation) = *operation { + stage.apply_operation(lane, channel, operation); + } + } + } + } + } + // REALTIME_POLICY_END +} + +/// A pull source of timed fader operations for one block (issue #1638 D3). +/// +/// Offsets are frames from the block's first frame. The stage calls [`Self::next_offset`] to +/// learn where the next operations sit, renders up to that frame, then calls [`Self::apply`] with +/// that offset. An offset at or past the block's length is never applied by that block: the stage +/// stops pulling and leaves it with the source. +/// +/// # Contract +/// +/// - Offsets must rise strictly within a block: each [`Self::next_offset`] after an +/// [`Self::apply`] is greater than the offset just applied. A source that repeats or lowers an +/// offset breaks this contract: the stage stops pulling for the rest of the block (a +/// `debug_assert` fails in debug builds) and sets +/// [`BuiltinProcessReport::fader_operations_stopped`]. +/// - A source must keep every operation it did not apply. [`Self::next_offset`] consumes +/// nothing; only [`Self::apply`] consumes, and only the operations at the offset it is given. +/// An operation the stage did not pull (at or past the block's end, or after a stop) belongs to +/// the source, which offers it again, rebased to the next block, or reports it lost. +pub trait FaderOperationSource { + /// The offset of the next operations, without consuming them; `None` when there are none. + fn next_offset(&mut self) -> Option; + + /// Pushes every operation at `offset` into `sink` and consumes them. + fn apply(&mut self, offset: u32, sink: &mut FaderOperationSink); +} + /// The live-control fader and mute section of one track (issue #140 B). /// /// # The ramped-fader decision, and why it is a separate type @@ -4750,8 +5126,9 @@ impl FaderMuteRampBuiltins { /// Retarget one or both lanes' fader gain in decibels over an explicit ramp window. /// - /// A muted lane keeps its `0.0` target: the new gain is remembered and takes effect when the - /// lane is unmuted, exactly as a physical console's fader does. + /// While a lane is muted (settled, or ramping toward `0.0`) the new gain is only remembered: + /// the mute ramp runs on, and the gain takes effect when the lane is unmuted, exactly as a + /// physical console's fader does (issue #1638, README F22). /// /// # Errors /// @@ -4792,6 +5169,66 @@ impl FaderMuteRampBuiltins { BuiltinProcessReport::default() } + // REALTIME_POLICY_BEGIN + /// Renders one already-validated block with timed operations inside it (issue #1638 D3). + /// + /// The only lane is `0`. Bit for bit what [`Self::process`] renders over the block split at + /// each operation's offset, with the operations applied by [`Self::apply_fader_operation`]. + pub fn process_with( + &mut self, + block: DualMonoBlock<'_>, + operations: &mut S, + ) -> BuiltinProcessReport { + let frames = block.left.len(); + let stopped = self + .stage + .process_with(block.left, block.right, frames, 1, operations); + BuiltinProcessReport { + fader_operations_stopped: u64::from(stopped), + ..BuiltinProcessReport::default() + } + } + // REALTIME_POLICY_END + + /// Applies one timed operation now, at the block boundary (issue #1638 D1 and D2). + /// + /// # Errors + /// + /// [`FaderOperationError::Gain`] when the gain is not finite or is negative. + pub fn apply_fader_operation( + &mut self, + lanes: BuiltinLaneSelector, + operation: FaderOperation, + ) -> Result<(), FaderOperationError> { + let operation = checked_operation(operation)?; + for channel in 0..2 { + if lanes.covers(channel) { + self.stage.apply_operation(0, channel, operation); + } + } + Ok(()) + } + + /// Whether one lane's gain is held (`0` left, `1` right): muted, or in a mute or unmute ramp + /// (issue #1638 D2). `None` for a lane above `1`. + #[must_use] + pub const fn gain_held(&self, lane: usize) -> Option { + if lane > 1 { + return None; + } + Some(self.stage.gain_held(0, lane)) + } + + /// Frames left in one lane's ramp (`0` left, `1` right); `0` when settled. `None` for a lane + /// above `1`. + #[must_use] + pub const fn ramp_remaining(&self, lane: usize) -> Option { + if lane > 1 { + return None; + } + Some(self.stage.ramp_remaining(0, lane)) + } + /// Completes a settled fader over a graph block whose shape was validated by preparation. /// /// This narrow raw-slice entry point is used only by the graph split-owner completion path; diff --git a/crates/builtins/src/tests.rs b/crates/builtins/src/tests.rs index 94ffb5f68..d5b17ab31 100644 --- a/crates/builtins/src/tests.rs +++ b/crates/builtins/src/tests.rs @@ -1193,3 +1193,43 @@ fn fused_fader_matrix_takes_the_select_free_arm_only_without_an_identity_lane() "per-track instant identity" ); } + +/// Issue #1638 D3: the sink refuses a padding lane, a bad gain and a second operation of one +/// kind on one lane and channel, and a refusal queues nothing on either channel. +#[test] +fn the_fader_operation_sink_refuses_without_queuing() { + use super::{FaderOperation, FaderOperationError, FaderOperationSink}; + let mut sink = FaderOperationSink::new(2); + let gain = FaderOperation::SetGain { gain: 0.5 }; + assert_eq!( + sink.push(2, BuiltinLaneSelector::Both, gain), + Err(FaderOperationError::Lane) + ); + for bad in [f32::NAN, f32::INFINITY, -0.25] { + assert_eq!( + sink.push( + 0, + BuiltinLaneSelector::Both, + FaderOperation::RememberGain { gain: bad } + ), + Err(FaderOperationError::Gain) + ); + } + assert_eq!(sink.push(0, BuiltinLaneSelector::Right, gain), Ok(())); + assert_eq!( + sink.push(0, BuiltinLaneSelector::Both, gain), + Err(FaderOperationError::Duplicate) + ); + assert!( + sink.gain[0][0].is_none(), + "a refused Both queued nothing on the left" + ); + let mute = FaderOperation::SetMute { + muted: true, + samples: 0, + }; + assert_eq!(sink.push(0, BuiltinLaneSelector::Both, mute), Ok(())); + assert_eq!(sink.push(0, BuiltinLaneSelector::Left, gain), Ok(())); + assert_eq!(sink.gain[1][0], Some(gain)); + assert_eq!(sink.mute[0][0], Some(mute)); +} diff --git a/crates/builtins/tests/fader_ramp.rs b/crates/builtins/tests/fader_ramp.rs index 1890e27e5..10496afff 100644 --- a/crates/builtins/tests/fader_ramp.rs +++ b/crates/builtins/tests/fader_ramp.rs @@ -372,3 +372,875 @@ fn a_long_fader_or_mute_move_stays_inside_its_endpoints() { } } } + +// --------------------------------------------------------------------------------------------- +// Issue #1638: timed operations inside a block on the fader stage. +// --------------------------------------------------------------------------------------------- + +use bench_support::alloc::{assert_installed, current_thread_counters, current_thread_delta_since}; +use effect_contract::BankWidth; +use lane::Backend; + +/// One timed operation: frame offset in the block, addressed lane index, operation. +type Timed = (u32, usize, FaderOperation); + +/// A pull source over a fixed slice of timed operations, sorted by offset. Allocation-free. +struct SliceSource<'a> { + operations: &'a [Timed], + index: usize, +} + +impl<'a> SliceSource<'a> { + fn new(operations: &'a [Timed]) -> Self { + Self { + operations, + index: 0, + } + } +} + +/// Lane index `i` is lane `i`, both channels, on a bank; and channel `i` of lane `0` on the +/// scalar track, whose two channels are its two independent lanes. +fn address(scalar: bool, index: usize) -> (usize, BuiltinLaneSelector) { + match (scalar, index) { + (true, 0) => (0, BuiltinLaneSelector::Left), + (true, _) => (0, BuiltinLaneSelector::Right), + (false, lane) => (lane, BuiltinLaneSelector::Both), + } +} + +/// Builds one fresh subject. +type Build = Box Subject>; + +/// The operations of one block, by block index. +type OpsFor = Box Vec>; + +/// The three users of the stage, behind one shape so every gate runs on each of them. +enum Subject { + Bank(Box), + Scalar(FaderMuteRampBuiltins), +} + +struct Source<'a> { + inner: SliceSource<'a>, + scalar: bool, +} + +impl FaderOperationSource for Source<'_> { + fn next_offset(&mut self) -> Option { + self.inner + .operations + .get(self.inner.index) + .map(|(offset, ..)| *offset) + } + + fn apply(&mut self, offset: u32, sink: &mut FaderOperationSink) { + while let Some(&(at, index, operation)) = self.inner.operations.get(self.inner.index) { + if at != offset { + break; + } + let (lane, channels) = address(self.scalar, index); + sink.push(lane, channels, operation) + .expect("a valid operation"); + self.inner.index += 1; + } + } +} + +impl Subject { + fn subjects() -> Vec<(&'static str, Build)> { + let mut subjects: Vec<(&'static str, Build)> = + vec![("simd4", Box::new(|| Self::bank(Backend::Simd4)))]; + #[cfg(target_feature = "avx2")] + subjects.push(("simd8", Box::new(|| Self::bank(Backend::Simd8)))); + subjects.push(( + "scalar", + Box::new(|| { + Self::Scalar( + FaderMuteRampBuiltins::new(parameters(-2.0, -5.0, false, false)) + .expect("fader"), + ) + }), + )); + subjects + } + + /// A bank with one padding lane, so padding lanes ride along inert. + fn bank(backend: Backend) -> Self { + let width = BankWidth::for_backend(backend).expect("bank width"); + let members = width.lanes() as usize - 1; + let faders = (0..members) + .map(|lane| parameters(-2.0 - lane as f32, -5.0 + lane as f32, false, false)) + .collect(); + Self::Bank(Box::new( + BuiltinFaderBank::new(backend, width, faders).expect("fader bank"), + )) + } + + const fn scalar(&self) -> bool { + matches!(self, Self::Scalar(_)) + } + + fn width(&self) -> usize { + match self { + Self::Bank(bank) => bank.width().lanes() as usize, + Self::Scalar(_) => 1, + } + } + + fn live_gain(&mut self, index: usize, db: f32, window: u32) { + let (lane, channels) = address(self.scalar(), index); + match self { + Self::Bank(bank) => bank.set_fader_db(lane, channels, db, window), + Self::Scalar(track) => track.set_fader_db(channels, db, window), + } + .expect("domain"); + } + + fn live_mute(&mut self, index: usize, muted: bool, window: u32) { + let (lane, channels) = address(self.scalar(), index); + match self { + Self::Bank(bank) => bank.set_mute(lane, channels, muted, window).expect("lane"), + Self::Scalar(track) => track.set_mute(channels, muted, window), + } + } + + fn apply(&mut self, index: usize, operation: FaderOperation) { + let (lane, channels) = address(self.scalar(), index); + match self { + Self::Bank(bank) => bank.apply_fader_operation(lane, channels, operation), + Self::Scalar(track) => track.apply_fader_operation(channels, operation), + } + .expect("a valid operation"); + } + + fn held(&self, index: usize) -> bool { + match self { + Self::Bank(bank) => bank.gain_held(index, 0).expect("member lane"), + Self::Scalar(track) => track.gain_held(index).expect("a channel"), + } + } + + fn process(&mut self, left: &mut [f32], right: &mut [f32], start: u64) { + match self { + Self::Bank(bank) => { + let frames = (left.len() / bank.width().lanes() as usize) as u32; + bank.process(left, right, frames); + } + Self::Scalar(track) => { + track.process(DualMonoBlock::new(left, right, start).expect("block")); + } + } + } + + fn process_with(&mut self, left: &mut [f32], right: &mut [f32], start: u64, ops: &[Timed]) { + let mut source = Source { + inner: SliceSource::new(ops), + scalar: self.scalar(), + }; + match self { + Self::Bank(bank) => { + let frames = (left.len() / bank.width().lanes() as usize) as u32; + bank.process_with(left, right, frames, &mut source); + } + Self::Scalar(track) => { + track.process_with( + DualMonoBlock::new(left, right, start).expect("block"), + &mut source, + ); + } + } + assert!( + source.next_offset().is_none(), + "every operation was applied" + ); + } + + /// The reference: `process` up to each offset, the operations applied through the setter, + /// then `process` over the rest. + fn process_split(&mut self, left: &mut [f32], right: &mut [f32], start: u64, ops: &[Timed]) { + let width = self.width(); + let frames = left.len() / width; + let mut position = 0; + for &(offset, index, operation) in ops { + let at = offset as usize; + if at > position { + self.process( + &mut left[position * width..at * width], + &mut right[position * width..at * width], + start + position as u64, + ); + position = at; + } + self.apply(index, operation); + } + if position < frames { + self.process( + &mut left[position * width..], + &mut right[position * width..], + start + position as u64, + ); + } + } +} + +/// A deterministic block with both signs and no zero, distinct per frame and lane. +fn signal(frames: usize, width: usize, block: usize) -> (Vec, Vec) { + let at = |index: usize, salt: usize| { + let phase = (block * 7919 + index * 31 + salt) % 211; + (phase as f32 - 105.5) / 97.0 + }; + let samples = frames * width; + ( + (0..samples).map(|index| at(index, 3)).collect(), + (0..samples).map(|index| at(index, 101)).collect(), + ) +} + +fn bits(values: &[f32]) -> Vec { + values.iter().map(|value| value.to_bits()).collect() +} + +/// The state a lane is in when the block with the operation starts. +#[derive(Clone, Copy, Debug)] +enum LaneState { + Settled, + Ramping, + RampEndsBeforeOffset, + Muted, + MuteRamp, + UnmuteRamp, +} + +fn prime(subject: &mut Subject, index: usize, state: LaneState, offset: u32) { + match state { + LaneState::Settled => {} + LaneState::Ramping => subject.live_gain(index, -14.0, 300), + LaneState::RampEndsBeforeOffset => subject.live_gain(index, -14.0, (offset / 2).max(1)), + LaneState::Muted => subject.live_mute(index, true, 0), + LaneState::MuteRamp => subject.live_mute(index, true, 300), + LaneState::UnmuteRamp => { + subject.live_mute(index, true, 0); + subject.live_mute(index, false, 300); + } + } +} + +const KINDS: [FaderOperation; 6] = [ + FaderOperation::RetargetGain { + gain: 0.3, + samples: 40, + }, + FaderOperation::RetargetGain { + gain: 1.7, + samples: 1_000, + }, + FaderOperation::SetGain { gain: 0.7 }, + FaderOperation::SetMute { + muted: true, + samples: 24, + }, + FaderOperation::SetMute { + muted: false, + samples: 24, + }, + FaderOperation::RememberGain { gain: 0.4 }, +]; + +/// Gate 1: one `process_with` call is bit for bit `process` split at the offsets with the +/// operations applied between, at every width, for every kind and lane state, and the block after +/// it agrees too (so the state left behind is the same). +/// +/// Red if a piece reuses a stale countdown or an operation lands a sample early or late. Each width +/// is compared only with its own split, so a divergence between widths is not this test's claim. +#[test] +fn an_operation_inside_a_block_renders_the_split_blocks_bits() { + let states = [ + LaneState::Settled, + LaneState::Ramping, + LaneState::RampEndsBeforeOffset, + LaneState::Muted, + LaneState::MuteRamp, + LaneState::UnmuteRamp, + ]; + let mut checked = 0; + for (name, build) in Subject::subjects() { + for (quantum, offsets) in [(128_usize, &[1_u32, 63, 64, 127][..]), (100, &[1, 50, 99])] { + for &offset in offsets { + for state in states { + for operation in KINDS { + // One lane, then two lanes with operations at different offsets. + let other = (offset + quantum as u32 / 3) % quantum as u32; + let other = if other == offset { offset - 1 } else { other }; + let mut two = vec![(offset, 0, operation), (other, 1, operation)]; + two.sort_by_key(|(at, ..)| *at); + for ops in [vec![(offset, 0, operation)], two] { + let mut whole = build(); + let mut split = build(); + for index in 0..2 { + prime(&mut whole, index, state, offset); + prime(&mut split, index, state, offset); + } + let width = whole.width(); + for block in 0..2 { + let (mut left, mut right) = signal(quantum, width, block); + let (mut split_left, mut split_right) = + (left.clone(), right.clone()); + let start = (block * quantum) as u64; + let ops: &[Timed] = if block == 0 { &ops } else { &[] }; + whole.process_with(&mut left, &mut right, start, ops); + split.process_split(&mut split_left, &mut split_right, start, ops); + let context = format!( + "{name} q={quantum} o={offset} {state:?} {operation:?} \ + lanes={} block={block}", + ops.len() + ); + assert_eq!(bits(&left), bits(&split_left), "left: {context}"); + assert_eq!(bits(&right), bits(&split_right), "right: {context}"); + } + checked += 1; + } + } + } + } + } + } + assert!(checked >= 2 * 7 * 6 * 6 * 2, "the matrix ran: {checked}"); +} + +/// Operations at offset `0` apply before the first frame, and an operation past the block stays +/// with the source. +#[test] +fn an_operation_at_offset_zero_is_block_entry_and_one_past_the_block_waits() { + for (name, build) in Subject::subjects() { + let mut whole = build(); + let mut split = build(); + let width = whole.width(); + let ops = [(0, 0, FaderOperation::SetGain { gain: 0.25 })]; + let (mut left, mut right) = signal(64, width, 0); + let (mut split_left, mut split_right) = (left.clone(), right.clone()); + whole.process_with(&mut left, &mut right, 0, &ops); + split.process_split(&mut split_left, &mut split_right, 0, &ops); + assert_eq!(bits(&left), bits(&split_left), "{name}: offset 0"); + + let late = [(64, 0, FaderOperation::SetGain { gain: 0.5 })]; + let mut source = Source { + inner: SliceSource::new(&late), + scalar: whole.scalar(), + }; + let (mut left, mut right) = signal(64, width, 1); + let (mut plain_left, mut plain_right) = (left.clone(), right.clone()); + match &mut whole { + Subject::Bank(bank) => { + bank.process_with(&mut left, &mut right, 64, &mut source); + } + Subject::Scalar(track) => { + track.process_with( + DualMonoBlock::new(&mut left, &mut right, 64).expect("block"), + &mut source, + ); + } + } + split.process(&mut plain_left, &mut plain_right, 64); + assert_eq!(bits(&left), bits(&plain_left), "{name}: nothing applied"); + assert_eq!( + source.next_offset(), + Some(64), + "{name}: the late operation waits" + ); + } +} + +/// Renders `blocks` blocks of 128 on lane 0 with `ops_for(block)` applied, returning lane 0's +/// left samples in frame order. +fn lane_zero_render( + subject: &mut Subject, + blocks: usize, + ops_for: &dyn Fn(usize) -> Vec, + live_for: &dyn Fn(&mut Subject, usize), +) -> Vec { + let width = subject.width(); + let mut out = Vec::new(); + for block in 0..blocks { + live_for(subject, block); + let (mut left, mut right) = signal(128, width, block); + let ops = ops_for(block); + subject.process_with(&mut left, &mut right, (block * 128) as u64, &ops); + out.extend(left.iter().step_by(width)); + } + out +} + +/// Gate 2: gain moves during a 480-sample mute ramp are only remembered -- by a timed +/// `RetargetGain`, by `RememberGain` and by the live setter -- so the lane renders the bits of the +/// mute ramp alone, and an unmute then ramps to the last remembered gain. +/// +/// Red if a gain move during a mute ramp restarts it toward `0` (README F12/F22's artefact) or +/// the remembered gain is lost. +#[test] +fn gain_moves_during_a_mute_ramp_are_remembered_and_never_restart_it() { + const LAST_DB: f32 = -4.5; + let last = checked_fader_gain(LAST_DB).expect("domain"); + let mute_at_entry = |subject: &mut Subject, block: usize| { + if block == 0 { + subject.live_mute(0, true, 480); + } + }; + for (name, build) in Subject::subjects() { + let alone = lane_zero_render(&mut build(), 4, &|_| Vec::new(), &mute_at_entry); + let every_64 = |make: fn(f32) -> FaderOperation| { + move |block: usize| -> Vec { + (0..2) + .map(|half| { + let at = block * 128 + half * 64 + 1; + let gain = if at + 64 >= 480 { + last + } else { + 0.1 + at as f32 / 1_000.0 + }; + ((half * 64 + 1) as u32, 0, make(gain)) + }) + .filter(|(offset, ..)| block * 128 + (*offset as usize) < 480) + .collect() + } + }; + let cases: [(&str, OpsFor, bool); 3] = [ + ( + "RetargetGain", + Box::new(every_64(|gain| FaderOperation::RetargetGain { + gain, + samples: 32, + })), + false, + ), + ( + "RememberGain", + Box::new(every_64(|gain| FaderOperation::RememberGain { gain })), + false, + ), + ("live", Box::new(|_| Vec::new()), true), + ]; + for (case, ops, live) in cases { + let mut subject = build(); + let live_moves = |subject: &mut Subject, block: usize| { + mute_at_entry(subject, block); + if live && block * 128 < 480 { + let db = if block == 3 { + LAST_DB + } else { + -1.0 - block as f32 + }; + subject.live_gain(0, db, 32); + } + }; + let rendered = lane_zero_render(&mut subject, 4, &*ops, &live_moves); + assert_eq!( + bits(&rendered), + bits(&alone), + "{name} {case}: the mute ramp alone" + ); + assert!(subject.held(0), "{name} {case}: still muted"); + + // Unmute over 32 samples; the lane ramps to the last remembered gain and settles there. + let unmute = |block: usize| -> Vec { + if block == 0 { + vec![( + 0, + 0, + FaderOperation::SetMute { + muted: false, + samples: 32, + }, + )] + } else { + Vec::new() + } + }; + let after = lane_zero_render(&mut subject, 2, &unmute, &|_, _| {}); + let width = subject.width(); + let (input, _) = signal(128, width, 1); + // The live setter converts `LAST_DB` with the same function, so one coefficient. + let expected: Vec = input.iter().step_by(width).map(|x| x * last).collect(); + assert_eq!( + bits(&after[128..]), + bits(&expected), + "{name} {case}: the remembered gain" + ); + assert!(!subject.held(0), "{name} {case}: the unmute ramp ended"); + } + } +} + +/// Gate 3: at one offset, gain operations apply before mute operations whatever order they are +/// pushed in. An unmute and a gain move end at the new gain; a mute and a gain move end muted. +/// +/// Red if the stage applies them in push order: the unmute's ramp would then hold the gain, and +/// the lane would settle at its old gain. +#[test] +fn at_one_offset_the_gain_applies_before_the_mute() { + for (name, build) in Subject::subjects() { + let width = build().width(); + // Muted lane: unmute (pushed first) and a gain move at offset 10. + let mut subject = build(); + subject.live_mute(0, true, 0); + let ops = [ + ( + 10, + 0, + FaderOperation::SetMute { + muted: false, + samples: 32, + }, + ), + ( + 10, + 0, + FaderOperation::RetargetGain { + gain: 0.45, + samples: 32, + }, + ), + ]; + let mut source = Source { + inner: SliceSource::new(&ops), + scalar: subject.scalar(), + }; + let (mut left, mut right) = signal(128, width, 0); + match &mut subject { + Subject::Bank(bank) => { + bank.process_with(&mut left, &mut right, 128, &mut source); + } + Subject::Scalar(track) => { + track.process_with( + DualMonoBlock::new(&mut left, &mut right, 0).expect("block"), + &mut source, + ); + } + } + let after = lane_zero_render(&mut subject, 1, &|_| Vec::new(), &|_, _| {}); + let (input, _) = signal(128, width, 0); + let expected: Vec = input.iter().step_by(width).map(|x| x * 0.45).collect(); + assert_eq!( + bits(&after), + bits(&expected), + "{name}: unmuted to the new gain" + ); + + // Unmuted lane: mute (pushed first) and an exact gain set at offset 10. + let mut subject = build(); + let ops = [ + ( + 10, + 0, + FaderOperation::SetMute { + muted: true, + samples: 16, + }, + ), + (10, 0, FaderOperation::SetGain { gain: 0.9 }), + ]; + let mut source = Source { + inner: SliceSource::new(&ops), + scalar: subject.scalar(), + }; + let (mut left, mut right) = signal(128, width, 0); + match &mut subject { + Subject::Bank(bank) => { + bank.process_with(&mut left, &mut right, 128, &mut source); + } + Subject::Scalar(track) => { + track.process_with( + DualMonoBlock::new(&mut left, &mut right, 0).expect("block"), + &mut source, + ); + } + } + let lane: Vec = left.iter().step_by(width).copied().collect(); + assert!( + lane[26..].iter().all(|x| x.to_bits() == 0), + "{name}: ended muted" + ); + let after = lane_zero_render(&mut subject, 1, &|_| Vec::new(), &|_, _| {}); + assert!( + after.iter().all(|x| x.to_bits() == 0), + "{name}: stays muted" + ); + subject.live_mute(0, false, 0); + let after = lane_zero_render(&mut subject, 1, &|_| Vec::new(), &|_, _| {}); + let expected: Vec = input.iter().step_by(width).map(|x| x * 0.9).collect(); + assert_eq!( + bits(&after), + bits(&expected), + "{name}: the gain set before the mute" + ); + } +} + +/// D2: the held flag follows the mute ramp. It is set by a windowed mute change, cleared the +/// frame the countdown ends, cleared by a live retarget during an unmute ramp, and carried +/// across a plan swap's lane export and import. +#[test] +fn the_held_flag_follows_the_mute_ramp() { + let mut bank = match Subject::bank(Backend::Simd4) { + Subject::Bank(bank) => bank, + Subject::Scalar(_) => unreachable!(), + }; + let block = |bank: &mut BuiltinFaderBank, frames: u32| { + let mut left = vec![0.5_f32; frames as usize * 4]; + let mut right = left.clone(); + bank.process(&mut left, &mut right, frames); + }; + bank.set_mute(0, BuiltinLaneSelector::Left, false, 40) + .expect("lane"); + assert_eq!( + bank.gain_held(0, 0), + Some(true), + "an unmute ramp holds the gain" + ); + assert_eq!( + bank.gain_held(0, 1), + Some(false), + "the right channel is free" + ); + block(&mut bank, 39); + assert_eq!(bank.ramp_remaining(0, 0), Some(1)); + assert_eq!(bank.gain_held(0, 0), Some(true), "one frame left"); + + // A plan swap carries the flag. + let state = bank.export_lane(0).expect("member"); + let mut successor = match Subject::bank(Backend::Simd4) { + Subject::Bank(bank) => bank, + Subject::Scalar(_) => unreachable!(), + }; + successor.import_lane(1, &state).expect("member"); + assert_eq!(successor.gain_held(1, 0), Some(true), "carried by the swap"); + + block(&mut bank, 1); + assert_eq!( + bank.gain_held(0, 0), + Some(false), + "cleared as the countdown ends" + ); + + bank.set_mute(0, BuiltinLaneSelector::Left, false, 40) + .expect("lane"); + bank.set_fader_db(0, BuiltinLaneSelector::Left, -6.0, 8) + .expect("domain"); + assert_eq!( + bank.gain_held(0, 0), + Some(false), + "a live retarget ends an unmute ramp" + ); + bank.set_mute(0, BuiltinLaneSelector::Left, true, 0) + .expect("lane"); + assert_eq!(bank.gain_held(0, 0), Some(true), "muted"); + assert_eq!(bank.gain_held(3, 0), None, "a padding lane"); + assert_eq!(bank.gain_held(0, 2), None, "no third channel"); +} + +/// The scalar fader's lane queries answer `None` for a lane above `1`, as the bank's do for a +/// channel above `1`. +/// +/// Red if a lane out of range wraps onto another lane (lane 2 read as lane 0's muted state). +#[test] +fn the_scalar_fader_refuses_a_lane_out_of_range() { + let mut track = FaderMuteRampBuiltins::new(parameters(0.0, 0.0, false, false)).expect("fader"); + track + .apply_fader_operation( + BuiltinLaneSelector::Left, + FaderOperation::SetMute { + muted: true, + samples: 0, + }, + ) + .expect("a valid operation"); + assert_eq!(track.gain_held(0), Some(true), "muted"); + assert_eq!(track.gain_held(1), Some(false), "the right lane is free"); + assert_eq!(track.gain_held(2), None, "no third lane"); + assert_eq!(track.ramp_remaining(0), Some(0), "a mute with no ramp"); + assert_eq!(track.ramp_remaining(2), None, "no third lane"); +} + +/// Gate 5: 1,000 blocks of `process_with` with operations allocate and free nothing. +#[test] +fn process_with_operations_never_allocates() { + assert_installed(); + static OPS: [Timed; 6] = [ + ( + 0, + 0, + FaderOperation::RetargetGain { + gain: 0.3, + samples: 40, + }, + ), + ( + 5, + 1, + FaderOperation::SetMute { + muted: true, + samples: 30, + }, + ), + (17, 0, FaderOperation::RememberGain { gain: 0.8 }), + (64, 1, FaderOperation::SetGain { gain: 0.5 }), + ( + 64, + 0, + FaderOperation::SetMute { + muted: false, + samples: 10, + }, + ), + ( + 127, + 1, + FaderOperation::SetMute { + muted: false, + samples: 0, + }, + ), + ]; + for (name, build) in Subject::subjects() { + let mut subject = build(); + let width = subject.width(); + let mut left = vec![0.25_f32; 128 * width]; + let mut right = vec![-0.25_f32; 128 * width]; + // Warm once so every process static is initialised outside the window. + subject.process_with(&mut left, &mut right, 0, &OPS); + let mark = current_thread_counters(); + for block in 1..=1_000_u64 { + subject.process_with(&mut left, &mut right, block * 128, &OPS); + } + let delta = current_thread_delta_since(mark); + assert_eq!(delta.allocations, 0, "{name}: allocations"); + assert_eq!(delta.deallocations, 0, "{name}: frees"); + } +} + +/// One fused fader-and-matrix subject: a settled fader bank with one padding lane and a matrix +/// bank of the same shape, so the fused settled path is eligible. +fn fused_pair(width: BankWidth) -> (BuiltinFaderBank, BuiltinMatrixBank) { + let backend = width.backend(); + let members = width.lanes() as usize - 1; + let faders = (0..members) + .map(|lane| parameters(-2.0 - lane as f32, -5.0 + lane as f32, false, false)) + .collect(); + let matrices = (0..members) + .map(|lane| { + let k = lane as f32 * 0.0625; + let matrix = Matrix2x2 { + ll: 0.75 - k, + lr: -0.3125 + k, + rl: 0.5 - 0.5 * k, + rr: -0.875 + k, + }; + (matrix, 0) + }) + .collect(); + ( + BuiltinFaderBank::new(backend, width, faders).expect("fader bank"), + BuiltinMatrixBank::new(backend, width, matrices).expect("matrix bank"), + ) +} + +/// Every member lane's fader and matrix state, as comparable text. +fn fused_words(fader: &BuiltinFaderBank, matrix: &BuiltinMatrixBank) -> String { + (0..fader.width().lanes() as usize) + .map(|lane| { + format!( + "{:?} {:?}", + fader.export_lane(lane), + matrix.export_lane(lane) + ) + }) + .collect::>() + .join("\n") +} + +/// D5: `try_process_settled_with_matrix_and_operations` declines, touching nothing, when an +/// operation sits inside the block, and otherwise forwards to the fused settled path. Checked +/// against a twin pair driven by the paths it chooses between. +#[test] +fn the_fused_settled_path_declines_only_an_operation_inside_the_block() { + const FRAMES: u32 = 128; + let held = FaderOperation::RetargetGain { + gain: 0.3, + samples: 40, + }; + for &width in BankWidth::ALL { + let lanes = width.lanes() as usize; + for (case, offset, declines) in [ + ("inside", Some(10_u32), true), + ("at the block end", Some(FRAMES), false), + ("none", None, false), + ] { + let ops: Vec = offset.map(|at| (at, 0, held)).into_iter().collect(); + let (mut fader, mut matrix) = fused_pair(width); + let (mut twin_fader, mut twin_matrix) = fused_pair(width); + let (mut left, mut right) = signal(FRAMES as usize, lanes, 0); + let (mut twin_left, mut twin_right) = (left.clone(), right.clone()); + let input = (bits(&left), bits(&right)); + let words = fused_words(&fader, &matrix); + let mut source = Source { + inner: SliceSource::new(&ops), + scalar: false, + }; + let fused = fader.try_process_settled_with_matrix_and_operations( + &mut matrix, + &mut left, + &mut right, + FRAMES, + &mut source, + ); + assert_eq!(fused, !declines, "width={lanes} {case}"); + assert_eq!( + source.next_offset(), + offset, + "width={lanes} {case}: the source keeps its operation" + ); + if declines { + assert_eq!( + (bits(&left), bits(&right)), + input, + "width={lanes} {case}: a declined block is untouched" + ); + assert_eq!( + fused_words(&fader, &matrix), + words, + "width={lanes} {case}: a decline changes no stage word" + ); + // The caller then renders the block with `process_with`; so does the twin. + fader.process_with(&mut left, &mut right, FRAMES, &mut source); + matrix.process(&mut left, &mut right, FRAMES); + let mut twin_source = Source { + inner: SliceSource::new(&ops), + scalar: false, + }; + twin_fader.process_with(&mut twin_left, &mut twin_right, FRAMES, &mut twin_source); + twin_matrix.process(&mut twin_left, &mut twin_right, FRAMES); + } else { + assert!( + twin_fader.try_process_settled_with_matrix( + &mut twin_matrix, + &mut twin_left, + &mut twin_right, + FRAMES, + ), + "width={lanes} {case}: the twin is settled" + ); + } + assert_eq!(bits(&left), bits(&twin_left), "width={lanes} {case}: left"); + assert_eq!( + bits(&right), + bits(&twin_right), + "width={lanes} {case}: right" + ); + assert_eq!( + fused_words(&fader, &matrix), + fused_words(&twin_fader, &twin_matrix), + "width={lanes} {case}: state" + ); + } + } +} diff --git a/crates/capi/Cargo.toml b/crates/capi/Cargo.toml index 87e83fe20..1e9d2edc6 100644 --- a/crates/capi/Cargo.toml +++ b/crates/capi/Cargo.toml @@ -28,7 +28,8 @@ builtins = { workspace = true, features = ["test-support"] } # #1309 D8: the control plane's observers and fault hooks, for capi's tests only. A dev-dependency # feature never reaches the release `capi` build (`scripts/check-capi-abi.sh` builds `-p capi`). control-plane = { workspace = true, features = ["test-support"] } -graph.workspace = true +# #1639: the block's held jump lengths (`graph::test_only_block_jump_lengths`), for capi's tests only. +graph = { workspace = true, features = ["test-support"] } # The tests write control values by row name (`control_core::{row_address, fader_edits, ...}`). protocol = { workspace = true, features = ["test-support"] } session.workspace = true diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 89a65fa8a..771fa8a9d 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -2351,7 +2351,8 @@ fn a_live_edit_emits_the_commit_events_of_a_rebuild() { } /// The model-only edits: #1260 D1's four and #1365's `control_smoothing`, each a change no -/// prepared plan reads. +/// prepared plan reads. The `control_smoothing` edit is model-only only on a session without stored +/// automation that reads its lengths (#1639 D3), so it comes before the automation upsert. fn model_only_edits(model: &SessionModel) -> [(&'static str, SessionEdit); 5] { let id = |text: &str| StableId::parse(text).expect("stable ID"); [ @@ -2379,6 +2380,18 @@ fn model_only_edits(model: &SessionModel) -> [(&'static str, SessionEdit); 5] { }, }, ), + ( + "control smoothing", + // Before the automation upsert: on a session with a fader entry the edit writes the + // plan's jump-length cell and is `live` (#1639 D3). + // The first write turns the `default` arm into the explicit lengths (#1561 D4a). + smoothing_edits(ControlSmoothing { + mute_ms: 1.0, + fader_ms: 2.0, + pan_ms: 3.0, + }) + .remove(0), + ), ( "automation upsert", SessionEdit::UpsertAutomation { @@ -2401,16 +2414,6 @@ fn model_only_edits(model: &SessionModel) -> [(&'static str, SessionEdit); 5] { }, }, ), - ( - "control smoothing", - // The first write turns the `default` arm into the explicit lengths (#1561 D4a). - smoothing_edits(ControlSmoothing { - mute_ms: 1.0, - fader_ms: 2.0, - pan_ms: 3.0, - }) - .remove(0), - ), ] } @@ -4912,6 +4915,7 @@ fn a_live_matrix_mode_switch_advances_the_epoch_and_a_fader_move_does_not() { /// The `SET_CONTROL` edit that writes `value` to the control that `entry` lists. fn set_listed(entry: &protocol::ControlEntry, value: f32) -> SessionEdit { SessionEdit::SetControl { + expected_effect: None, address: protocol::control_core::ControlAddress::try_from(&entry.address) .expect("a listed address"), value, @@ -5200,6 +5204,7 @@ mod set_control { fn set(address: ControlAddress, value: f32, ramp_samples: Option) -> SessionEdit { SessionEdit::SetControl { + expected_effect: None, address, value, ramp_samples, @@ -8047,6 +8052,7 @@ fn in_place_commands_equal_full_commands_through_the_c_abi() { let ramp = (row.ramp_row.is_some() && draw.next(3) == 0).then(|| [0, 64, 480][draw.next(3)]); edits.push(SessionEdit::SetControl { + expected_effect: None, address: protocol::control_core::ControlAddress::try_from(&entry.address) .expect("a listed address"), value, @@ -8439,3 +8445,166 @@ fn in_place_command_timing_line() { control_plane::test_force_full_preparation(false); } } + +/// `document` with one stored fader ride on its first track (builtin 5, both lanes). +fn with_fader_automation(document: &str) -> String { + with_model(document, |model| { + let target = session::ControlAddress { + owner: session::Owner::Strip(model.tracks[0].id.clone()), + section: session::Section::Builtins, + control: 5, + lane: session::ParameterChannel::Both, + }; + model.automation.push(session::Automation { + id: StableId::parse("fader-ride").expect("automation ID"), + target, + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: 960, + start_value: 0.0, + end_value: -3.0, + unit: session::ParameterUnit::Db, + }], + }); + }) +} + +/// #1639 gate 3. A playing session with a fader entry takes `SET_CONTROL`s of its three +/// `control_smoothing` rows: the response path is `live`, the provider epoch is unchanged with no +/// candidate, and the plan holds the new lengths from the next block, which allocates and frees +/// nothing. The same edit on a session without stored automation reports `model_only` and leaves +/// the prepared lengths held. +/// +/// Test value: red if the commit does not write the cell (the next block still holds the prepared +/// lengths), or reports the path from anything but the classifier's jump-length value. +#[test] +fn a_control_smoothing_edit_on_an_automated_session_writes_the_jump_lengths_live() { + bench_support::alloc::assert_installed(); + let fast = ControlSmoothing { + mute_ms: 1.0, + fader_ms: 2.0, + pan_ms: 3.0, + }; + // The default table at 48 kHz: fader 20 ms, mute 10 ms, pan 20 ms. + let prepared = graph::GraphJumpLengths { + fader_samples: 960, + mute_samples: 480, + pan_samples: 960, + }; + let written = graph::GraphJumpLengths { + fader_samples: 96, + mute_samples: 48, + pan_samples: 144, + }; + let plain = long_session(10, 48_000); + for (document, path, held) in [ + ( + with_fader_automation(&plain), + protocol::EditPath::Live, + written, + ), + (plain, protocol::EditPath::ModelOnly, prepared), + ] { + let mut rig = Rig::new(&document); + for _ in 0..2 { + rig.step(); + } + assert_eq!( + graph::test_only_block_jump_lengths(), + Some(prepared), + "a fresh plan holds the prepared lengths" + ); + let (revision, _, epoch, _) = rig.summary(); + let bytes = rig.transaction(&smoothing_edits(fast)); + let (result, response) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK); + assert_eq!(edit_path_of(&response), path); + let (after, _, after_epoch, pending) = rig.summary(); + assert_eq!( + (after, after_epoch, pending), + (revision + 1, epoch, 0), + "{path:?}: one revision, the same provider epoch, no candidate" + ); + let (_, used) = rig.step_counted(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "{path:?}: the block that reads the cell allocates and frees nothing" + ); + assert_eq!( + graph::test_only_block_jump_lengths(), + Some(held), + "{path:?}: the next block's lengths" + ); + rig.step(); + assert_eq!(graph::test_only_block_jump_lengths(), Some(held)); + } +} + +/// #1639 D4. One transaction adds a track (a structural edit, so a successor plan) and changes the +/// three `control_smoothing` rows: the path is `rebuild`, and once render adopts the successor +/// (the watermark covers the revision, the provider epoch moves) the plan holds the new lengths. +/// The session has no stored automation, so no live write reaches any plan's cell: only the +/// successor's seed can carry the new lengths. +/// +/// Test value: red if the successor's cell is seeded from anything but the committed model that +/// holds the edit (the model before the transaction, or a constant), so a rebuild silently drops +/// a `control_smoothing` change that came with it. +#[test] +fn a_successor_prepared_with_a_control_smoothing_edit_holds_the_new_lengths() { + let fast = ControlSmoothing { + mute_ms: 1.0, + fader_ms: 2.0, + pan_ms: 3.0, + }; + // The default table at 48 kHz: fader 20 ms, mute 10 ms, pan 20 ms. + let prepared = graph::GraphJumpLengths { + fader_samples: 960, + mute_samples: 480, + pan_samples: 960, + }; + let written = graph::GraphJumpLengths { + fader_samples: 96, + mute_samples: 48, + pan_samples: 144, + }; + let document = long_session(10, 48_000); + let model = parse_session_json(&document).expect("model"); + let mut rig = Rig::new(&document); + for _ in 0..2 { + rig.step(); + } + assert_eq!( + graph::test_only_block_jump_lengths(), + Some(prepared), + "a fresh plan holds the prepared lengths" + ); + let (revision, _, epoch, _) = rig.summary(); + let mut edits = add_muted_track(&model, "added").to_vec(); + edits.extend(smoothing_edits(fast)); + let bytes = rig.transaction(&edits); + let (result, response) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK); + assert_eq!(edit_path_of(&response), protocol::EditPath::Rebuild); + assert_eq!( + rig.summary().3, + 1, + "the structural edit prepares a candidate" + ); + let mut guard = 0; + while rig.summary().3 != 0 { + rig.step(); + guard += 1; + assert!(guard < 16, "the candidate is adopted"); + } + let (after, _, after_epoch, _) = rig.summary(); + assert_eq!(after, revision + 1, "one revision"); + assert_ne!(after_epoch, epoch, "the successor renders"); + rig.step(); + assert_eq!( + graph::test_only_block_jump_lengths(), + Some(written), + "the successor's cell holds the committed lengths" + ); +} diff --git a/crates/capi/src/runtime/tests.rs b/crates/capi/src/runtime/tests.rs index f6fbff00c..506f9fbf8 100644 --- a/crates/capi/src/runtime/tests.rs +++ b/crates/capi/src/runtime/tests.rs @@ -105,8 +105,10 @@ const ALL_COMMAND_RESPONSE_VECTORS: [&str; 10] = [ // #1093 re-pin: the first descriptor's rack is `console` (5), was `simd1` (1). "4d49534f43544c0001000000300002000c000000d000000003000000000000002a00000000000000050000000000000001000401080000002a0000000000000002000401080000000100000000000000030003010400000001000000000000000400080101000000000000000000000005000b01880000000500000000000000010003010400000001000000000000000200010101000000010000000000000003000b013800000003000000000000000100010101000000020000000000000002000900030000006571300000000000030001010100000006000000000000000400010001000000010000000000000005000900030000006571300000000000", "4d49534f43544c0001000000300002000d0000006800000004000000000000002a00000000000000070000000000000001000401080000002a00000000000000020004010800000001000000000000000300030104000000010000000000000004000801010000000000000000000000050002010200000000000000000000000600020102000000080000000000000007000a0100000000", - "4d49534f43544c000100000030000200070000003000000005000000000000002a000000000000000300000000000000010001010100000001000000000000000200040108000000000000000000000003000401080000000000000000000000", - "4d49534f43544c000100000030000200080000003000000006000000000000002a000000000000000300000000000000010001010100000002000000000000000200040108000000000000000000000003000401080000000000000000000000", + // #1635 re-pin: the transport snapshots (`TRANSPORT_GET`, `TRANSPORT_SET`) drop the retired + // position, field 2: two fields, payload length 0x20 (was 0x30). + "4d49534f43544c000100000030000200070000002000000005000000000000002a0000000000000002000000000000000100010101000000010000000000000003000401080000000000000000000000", + "4d49534f43544c000100000030000200080000002000000006000000000000002a0000000000000002000000000000000100010101000000020000000000000003000401080000000000000000000000", "4d49534f43544c000100000030000200090000005000000007000000000000002a00000000000000060000000000000001000d01000000000200030104000000000000000000000003000d0100000000040003010400000000000000000000000500080101000000000000000000000006000101010000000100000000000000", // #1312 re-pin: the counter snapshot gains `LIVE_VALUES_SUPERSEDED` (id 16), value 0, as a // third value (a 40-byte `CounterValue` message): the length 0x70 became 0xa0 and the @@ -2143,7 +2145,8 @@ fn the_retired_automation_command_is_refused_at_decode_and_not_advertised() { fn all_five_event_families_cross_c_dequeue_with_exact_oracle_bytes() { const RESPONSES: [&str; 7] = [ "4d49534f43544c000100000030000200090000005800000001000000000000002a00000000000000060000000000000001000d010400000001000000000000000200030104000000010000000000000003000d0100000000040003010400000000000000000000000500080101000000010000000000000006000101010000000100000000000000", - "4d49534f43544c000100000030000200080000003000000002000000000000002a000000000000000300000000000000010001010100000002000000000000000200040108000000000000000000000003000401080000000000000000000000", + // #1635 re-pin: the transport snapshot drops the retired position (field 2). + "4d49534f43544c000100000030000200080000002000000002000000000000002a0000000000000002000000000000000100010101000000020000000000000003000401080000000000000000000000", "4d49534f43544c000100000030000200030000002000000004000000000000002b0000000000000002000000000000000100030104000000010000000000000002000101010000000200000000000000", "4d49534f43544c000100000030000200090000005800000005000000000000002b00000000000000060000000000000001000d010400000001000000000000000200030104000000010000000000000003000d0100000000040003010400000000000000000000000500080101000000000000000000000006000101010000000100000000000000", "4d49534f43544c000100000030000200090000005800000006000000000000002b00000000000000060000000000000001000d010800000001000000020000000200030104000000010000000000000003000d0100000000040003010400000000000000000000000500080101000000000000000000000006000101010000000100000000000000", @@ -2151,7 +2154,9 @@ fn all_five_event_families_cross_c_dequeue_with_exact_oracle_bytes() { "4d49534f43544c000100000030000200090000005800000008000000000000002b00000000000000060000000000000001000d01000000000200030104000000000000000000000003000d01040000000600000000000000040003010400000001000000000000000500080101000000000000000000000006000101010000000100000000000000", ]; const EVENTS: [&str; 6] = [ - "4d49534f43544c000100000030000300108000005000000000000000000000002a0000000000000005000000000000000100040108000000010000000000000002000101010000000200000000000000030004010800000000000000000000000400040108000000000000000000000005000400080000000200000000000000", + // #1635 re-pin: the `TRANSPORT_STATE` event drops the retired position (field 3): four + // fields, payload length 0x40 (was 0x50). + "4d49534f43544c000100000030000300108000004000000000000000000000002a00000000000000040000000000000001000401080000000100000000000000020001010100000002000000000000000400040108000000000000000000000005000400080000000200000000000000", "4d49534f43544c000100000030000300018000004000000000000000000000002b000000000000000400000000000000010004010800000002000000000000000200040108000000040000000000000003000401080000002a0000000000000004000301040000000100000000000000", "4d49534f43544c000100000030000300308000006000000000000000000000002b00000000000000010000000000000001000b015800000004000000000000000100090114000000636170692e72656e6465722e616374697669747900000000020001010100000001000000000000000600040008000000800000000000000007000400080000000100000000000000", "4d49534f43544c000100000030000300208000004800000000000000000000002b00000000000000040000000000000001000401080000008000000000000000020002010200000001000000000000000300020102000000100000000000000004000a011000000001000000010001000000000000000000", @@ -2183,7 +2188,6 @@ fn all_five_event_families_cross_c_dequeue_with_exact_oracle_bytes() { ExpectedRevision::Exact(revision), protocol::CommandPayload::TransportSet(protocol::TransportSetRequest { state: protocol::TransportState::Playing, - position: Some(protocol::SampleTime(0)), }), ); assert_eq!( @@ -2652,7 +2656,6 @@ fn capi_controller_dispatches_every_advertised_command_family() { ExpectedRevision::Exact(SessionRevision(42)), protocol::CommandPayload::TransportSet(protocol::TransportSetRequest { state: protocol::TransportState::Playing, - position: Some(protocol::SampleTime(0)), },), StatusCode::Ok, 42, @@ -2736,11 +2739,12 @@ fn exported_c_replay_revision_event_and_publication_pressure_statuses_are_exact( const REUSE: &str = "4d49534f43544c000100000030000200070009004800000001000000000000002a00000000000000020000000000000001000b01300000000200000000000000010009011000000070726f746f636f6c2e6661696c7572650200010101000000030000000000000002000301040000000000000000000000"; const EXPIRED: &str = "4d49534f43544c00010000003000020001000a004800000001000000000000002a00000000000000020000000000000001000b01300000000200000000000000010009011000000070726f746f636f6c2e6661696c7572650200010101000000030000000000000002000301040000000000000000000000"; const STALE: &str = "4d49534f43544c000100000030000200030007004800000013000000000000002a00000000000000020000000000000001000b01300000000200000000000000010009011000000070726f746f636f6c2e6661696c7572650200010101000000030000000000000002000301040000000000000000000000"; - const TRANSPORT_20: &str = "4d49534f43544c000100000030000200080000003000000014000000000000002a000000000000000300000000000000010001010100000001000000000000000200040108000000000000000000000003000401080000000000000000000000"; - const TRANSPORT_21: &str = "4d49534f43544c000100000030000200080000003000000015000000000000002a000000000000000300000000000000010001010100000002000000000000000200040108000000000000000000000003000401080000000000000000000000"; + // #1635 re-pin: the transport snapshot and the `TRANSPORT_STATE` event drop the retired position. + const TRANSPORT_20: &str = "4d49534f43544c000100000030000200080000002000000014000000000000002a0000000000000002000000000000000100010101000000010000000000000003000401080000000000000000000000"; + const TRANSPORT_21: &str = "4d49534f43544c000100000030000200080000002000000015000000000000002a0000000000000002000000000000000100010101000000020000000000000003000401080000000000000000000000"; const EVENT_FULL: &str = "4d49534f43544c00010000003000020003000b00b000000016000000000000002a00000000000000030000000000000001000b01380000000200000000000000010009011500000070726f746f636f6c2e6261636b7072657373757265000000020001010100000003000000000000000200030104000000000000000000000003000b005800000005000000000000000100010101000000040000000000000002000401080000000200000000000000030004010800000002000000000000000400020102000000010000000000000005000400080000000400000000000000"; - const TRANSPORT_EVENT_20: &str = "4d49534f43544c000100000030000300108000005000000000000000000000002a0000000000000005000000000000000100040108000000010000000000000002000101010000000100000000000000030004010800000000000000000000000400040108000000000000000000000005000400080000001400000000000000"; - const TRANSPORT_EVENT_21: &str = "4d49534f43544c000100000030000300108000005000000000000000000000002a0000000000000005000000000000000100040108000000020000000000000002000101010000000200000000000000030004010800000000000000000000000400040108000000000000000000000005000400080000001500000000000000"; + const TRANSPORT_EVENT_20: &str = "4d49534f43544c000100000030000300108000004000000000000000000000002a00000000000000040000000000000001000401080000000100000000000000020001010100000001000000000000000400040108000000000000000000000005000400080000001400000000000000"; + const TRANSPORT_EVENT_21: &str = "4d49534f43544c000100000030000300108000004000000000000000000000002a00000000000000040000000000000001000401080000000200000000000000020001010100000002000000000000000400040108000000000000000000000005000400080000001500000000000000"; const STRUCTURAL_23: &str = "4d49534f43544c000100000030000200030000002000000017000000000000002b0000000000000002000000000000000100030104000000010000000000000002000101010000000300000000000000"; const COMMIT_EVENT: &str = "4d49534f43544c000100000030000300018000004000000000000000000000002b000000000000000400000000000000010004010800000003000000000000000200040108000000170000000000000003000401080000002a0000000000000004000301040000000100000000000000"; const RETRY_24: &str = "4d49534f43544c000100000030000200030000002000000018000000000000002c0000000000000002000000000000000100030104000000010000000000000002000101010000000300000000000000"; @@ -2804,10 +2808,7 @@ fn exported_c_replay_revision_event_and_publication_pressure_statuses_are_exact( command_bytes_at_revision( request_id, ExpectedRevision::Exact(SessionRevision(42)), - protocol::CommandPayload::TransportSet(protocol::TransportSetRequest { - state, - position: Some(protocol::SampleTime(0)), - },), + protocol::CommandPayload::TransportSet(protocol::TransportSetRequest { state },), ), StatusCode::Ok, pinned_hex(expected) diff --git a/crates/capi/tests/resource_lifecycle.rs b/crates/capi/tests/resource_lifecycle.rs index 45f3ce492..f951ac83e 100644 --- a/crates/capi/tests/resource_lifecycle.rs +++ b/crates/capi/tests/resource_lifecycle.rs @@ -1214,6 +1214,12 @@ impl Budget { /// can hold (4,096 bytes here), the binding table and its transient copies. `capi_retained_bytes` /// measured 165,758 before and 186,614 after (x86-64; the rows do not depend on the lane width), /// so its ceiling is 186,614 plus 10 %, rounded up to 64: 205,275.4 -> 205,312. +/// +/// #1676 charges that reserve by the slots the session has had, capped by the keys one frame carries, +/// and not by the frame size. `capi_retained_bytes` measured 165,782 (x86-64; the rows do not depend +/// on the lane width), so its ceiling is 165,782 plus 10 %, rounded up to 64: 182,360.2 -> 182,400. +/// Batch 13b adds #1639's jump-length cell to every plan: measured 165,934 on the integrated tree +/// (x86-64), within the ceiling, so the ceiling stays. const REFERENCE_BUDGETS: [Budget; 19] = [ Budget { row: "graph_session_plus_plan_bytes", @@ -1320,8 +1326,8 @@ const REFERENCE_BUDGETS: [Budget; 19] = [ Budget { row: "capi_retained_bytes", value: |report| report.capi_retained_bytes, - eight_lanes: 205_312, - four_lanes: 205_312, + eight_lanes: 182_400, + four_lanes: 182_400, }, Budget { row: "largest_named_allocation_bytes", @@ -2731,6 +2737,7 @@ impl RaceEdits { fn solo(&mut self, track: usize, on: bool) -> SessionEdit { self.soloed = on.then_some(track); SessionEdit::SetControl { + expected_effect: None, address: protocol::control_core::ControlAddress { owner: protocol::control_core::Owner::Strip(self.tracks[track].clone()), section: protocol::control_core::Section::Own, @@ -2778,6 +2785,7 @@ impl RaceEdits { 3 => { self.follows = !self.follows; SessionEdit::SetControl { + expected_effect: None, address: protocol::control_core::ControlAddress { owner: protocol::control_core::Owner::Route( StableId::parse("race-send").expect("stable id"), diff --git a/crates/conformance/src/effect.rs b/crates/conformance/src/effect.rs index 12ae2ded7..a07917918 100644 --- a/crates/conformance/src/effect.rs +++ b/crates/conformance/src/effect.rs @@ -50,7 +50,7 @@ const PARAMETERS: [ParameterDescriptor; 1] = [ParameterDescriptor { maximum: Some(2.0), default_value: 1.0, mapping: ParameterMapping::Linear, - automation_rate: effect_contract::AutomationRate::Sample, + automation_rate: effect_contract::AutomationRate::Block, channel_policy: ParameterChannelPolicy::PerLane, smoothing: SmoothingRule::None, smoothing_samples: 0, @@ -82,8 +82,8 @@ const PORTS: [PortDescriptor; 3] = [ ]; const STATE_SIZES: StatePayloadSizes = StatePayloadSizes { common_bytes: 4, - left_bytes: 56, - right_bytes: 56, + left_bytes: 20, + right_bytes: 20, }; const fn quality(sample_rate: u32) -> QualityDescriptor { QualityDescriptor { @@ -199,7 +199,6 @@ impl NativeEffectFactory for DualAccumulatorDelayFactory { gain: [left, right], delay: [[0.0; 3]; 2], accumulator: [0.0; 2], - active: [None; 2], delay_index: 0, snapshot_calls: Cell::new(0), fault: self.fault, @@ -241,7 +240,6 @@ impl NativeEffectFactory for DualAccumulatorDelayFactory { gain: [1.0; 2], delay: [[0.0; 3]; 2], accumulator: [0.0; 2], - active: [None; 2], delay_index: 0, snapshot_calls: Cell::new(0), fault: FaultKind::None, @@ -293,7 +291,6 @@ impl PreparedNativeEffectBank for DualAccumulatorDelayBank { for frame in 0..block.frames as usize { for lane in 0..lanes { let index = frame * lanes + lane; - let sample = block.first_sample + frame as u64; let mut left = block.left[index]; let mut right = block.right[index]; if !canonical_finite(left) { @@ -308,8 +305,8 @@ impl PreparedNativeEffectBank for DualAccumulatorDelayBank { .sanitized_main_samples .saturating_add(1); } - let (left, recover_left) = self.lanes[lane].process_lane(0, left, sample); - let (right, recover_right) = self.lanes[lane].process_lane(1, right, sample); + let (left, recover_left) = self.lanes[lane].process_lane(0, left); + let (right, recover_right) = self.lanes[lane].process_lane(1, right); if recover_left { report.reports[lane].nonfinite_left_blocks = report.reports[lane].nonfinite_left_blocks.saturating_add(1); @@ -360,35 +357,12 @@ struct DualAccumulatorDelay { gain: [f32; 2], delay: [[f32; 3]; 2], accumulator: [f32; 2], - active: [Option; 2], delay_index: usize, snapshot_calls: Cell, fault: FaultKind, } impl DualAccumulatorDelay { - fn process_lane(&mut self, lane: usize, input: f32, sample: u64) -> (f32, bool) { - if let Some(span) = self.active[lane] { - match span.kind { - AutomationSpanKind::Step => { - if sample == span.start_sample { - self.gain[lane] = span.start_value; - } - if sample == span.end_sample { - self.gain[lane] = span.end_value; - self.active[lane] = None; - } - } - AutomationSpanKind::Linear | AutomationSpanKind::Exponential => { - if let Some(value) = effect_contract::automation_segment_value(span, sample) { - self.gain[lane] = value; - } - if sample == span.end_sample { - self.active[lane] = None; - } - } - AutomationSpanKind::Point => {} - } - } + fn process_lane(&mut self, lane: usize, input: f32) -> (f32, bool) { let delayed = self.delay[lane][self.delay_index]; self.delay[lane][self.delay_index] = input; let output = if self.bypass { @@ -413,7 +387,6 @@ impl PreparedNativeEffect for DualAccumulatorDelay { fn reset(&mut self, kind: ResetKind) { self.delay = [[0.0; 3]; 2]; self.accumulator = [0.0; 2]; - self.active = [None; 2]; if self.fault != FaultKind::StickyReset { self.delay_index = 0; } @@ -460,12 +433,10 @@ impl PreparedNativeEffect for DualAccumulatorDelay { for &lane in lanes { match span.kind { AutomationSpanKind::Point => self.gain[lane] = span.start_value, - _ => self.active[lane] = Some(*span), } } } for frame in 0..block.left.len() { - let sample = block.first_sample + frame as u64; let mut input = [block.left[frame], block.right[frame]]; for value in &mut input { if !canonical_finite(*value) { @@ -481,8 +452,8 @@ impl PreparedNativeEffect for DualAccumulatorDelay { } } } - let (mut left, recover_left) = self.process_lane(0, input[0], sample); - let (right, recover_right) = self.process_lane(1, input[1], sample); + let (mut left, recover_left) = self.process_lane(0, input[0]); + let (right, recover_right) = self.process_lane(1, input[1]); if self.fault == FaultKind::SharedLaneState { self.accumulator[1] = self.accumulator[0]; } @@ -527,14 +498,12 @@ impl PreparedNativeEffect for DualAccumulatorDelay { self.delay[0], self.gain[0], self.accumulator[0], - self.active[0], ); encode_lane( output.right, self.delay[1], self.gain[1], self.accumulator[1], - self.active[1], ); Ok(()) } @@ -562,36 +531,21 @@ impl PreparedNativeEffect for DualAccumulatorDelay { self.delay = [left.0, right.0]; self.gain = [left.1, right.1]; self.accumulator = [left.2, right.2]; - self.active = [left.3, right.3]; Ok(()) } } -fn encode_lane( - output: &mut [u8], - delay: [f32; 3], - gain: f32, - accumulator: f32, - active: Option, -) { +fn encode_lane(output: &mut [u8], delay: [f32; 3], gain: f32, accumulator: f32) { output.fill(0); for (index, value) in delay.into_iter().enumerate() { output[index * 4..index * 4 + 4].copy_from_slice(&value.to_bits().to_le_bytes()); } output[12..16].copy_from_slice(&gain.to_bits().to_le_bytes()); output[16..20].copy_from_slice(&accumulator.to_bits().to_le_bytes()); - if let Some(span) = active { - output[20] = 1; - output[24..28].copy_from_slice(&(span.kind as u32).to_le_bytes()); - output[28..36].copy_from_slice(&span.start_sample.to_le_bytes()); - output[36..44].copy_from_slice(&span.end_sample.to_le_bytes()); - output[44..48].copy_from_slice(&span.start_value.to_bits().to_le_bytes()); - output[48..52].copy_from_slice(&span.end_value.to_bits().to_le_bytes()); - } } -type LaneState = ([f32; 3], f32, f32, Option); +type LaneState = ([f32; 3], f32, f32); fn decode_lane(input: &[u8]) -> Result { - if input.len() != 56 || input[20] > 1 || input[21..24] != [0; 3] || input[52..56] != [0; 4] { + if input.len() != 20 { return Err(StatePayloadError { code: "effect.state.invalid", }); @@ -615,36 +569,7 @@ fn decode_lane(input: &[u8]) -> Result { code: "effect.state.invalid", }); } - let active = if input[20] == 0 { - None - } else { - let kind = AutomationSpanKind::from_raw(u32::from_le_bytes( - input[24..28].try_into().expect("bounded lane field"), - )) - .ok_or(StatePayloadError { - code: "effect.state.invalid", - })?; - let start_sample = - u64::from_le_bytes(input[28..36].try_into().expect("bounded lane field")); - let end_sample = u64::from_le_bytes(input[36..44].try_into().expect("bounded lane field")); - let start_value = read(44); - let end_value = read(48); - if !start_value.is_finite() || !end_value.is_finite() || end_sample <= start_sample { - return Err(StatePayloadError { - code: "effect.state.invalid", - }); - } - Some(PreparedAutomationSpan { - kind, - channel: ParameterChannel::Left, - parameter_index: 0, - start_sample, - end_sample, - start_value, - end_value, - }) - }; - Ok((delay, gain, accumulator, active)) + Ok((delay, gain, accumulator)) } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/crates/conformance/src/protocol_corpus.rs b/crates/conformance/src/protocol_corpus.rs index 2d6b9f457..321446d4e 100644 --- a/crates/conformance/src/protocol_corpus.rs +++ b/crates/conformance/src/protocol_corpus.rs @@ -225,6 +225,7 @@ pub fn complete_all_opcode_fixture() -> Vec { }, // #1553: the first fader of the track by its stable address, with a ramp of 240 samples. SessionEdit::SetControl { + expected_effect: None, address: control_core::ControlAddress { owner: control_core::Owner::Strip(track_id.clone()), section: control_core::Section::Builtins, @@ -234,6 +235,18 @@ pub fn complete_all_opcode_fixture() -> Vec { value: -3.0, ramp_samples: Some(240), }, + // #1678: an insert's parameter, with the native effect the edit was written for. + SessionEdit::SetControl { + address: control_core::ControlAddress { + owner: control_core::Owner::Strip(track_id.clone()), + section: control_core::Section::Inserts(id("eq1")), + control: 2, + lane: control_core::Lane::Both, + }, + value: 1.5, + ramp_samples: None, + expected_effect: Some(id("miso.parametric-eq")), + }, ] } @@ -320,6 +333,11 @@ pub struct RetiredCodeRow { /// `0x020b`, `0x020d`-`0x0210`, `0x0504`-`0x0507` and `0x0702`, spliced into a frame that /// otherwise carries a `SET_CONTROL` payload: an unallocated opcode, refused at decode /// (`MALFORMED_FRAME`) whatever the payload holds; +/// - the stored transport position (#1635), `TRANSPORT_SET` field 2, mandatory +/// (`UNKNOWN_REQUIRED_FIELD`) and optional (`MALFORMED_FRAME`): refused at decode, so the +/// transport state, the revision and the reliable lane stay as they were. The snapshot's field 2 +/// and the `TRANSPORT_STATE` event's field 3 are responses and events no endpoint decodes; +/// the codec's decoder tests refuse them in the same two flag forms; /// - a `SetTrackConsole` in a transaction with no `SetConsole` before it that changes the console /// (`VALIDATION_FAILED`, `session.edit.value_op_retired`); /// - the retired command message IDs `0x0004` (`PARAMETER_METADATA_GET`) and `0x0005` @@ -481,6 +499,19 @@ pub fn retired_code_rows() -> Vec { diagnostic: DECODE_REFUSAL, }); } + // The stored transport position (#1635): `TRANSPORT_SET` field 2 is refused at decode in both + // flag forms, before the transport state moves or a `TRANSPORT_STATE` event is queued. + for (flag, status) in [ + ("mandatory", UNKNOWN_REQUIRED_FIELD), + ("optional", MALFORMED_FRAME), + ] { + rows.push(RetiredCodeRow { + name: format!("retired.transport_set_field_2.{flag}"), + bytes: transport_set_with_position(flag == "mandatory"), + status, + diagnostic: DECODE_REFUSAL, + }); + } rows.push(RetiredCodeRow { name: "control.unknown_optional_track_field_12".to_owned(), bytes: upsert_track_with_field(&track, 12, false), @@ -495,12 +526,14 @@ pub fn retired_code_rows() -> Vec { const RETIRED_COUNTER_IDS: [u32; 5] = [2, 11, 12, 13, 14]; /// One complete command frame, request ID 1: exact revision 7 for the state-changing -/// `TELEMETRY_CONFIGURE`, any revision for the read. +/// `TELEMETRY_CONFIGURE` and `TRANSPORT_SET`, any revision for the read. fn counter_command_frame(payload: CommandPayload<'_>) -> Vec { let frame = TypedCommandFrame { request_id: RequestId::new(1).expect("literal request ID"), expected_revision: match payload { - CommandPayload::TelemetryConfigure(_) => ExpectedRevision::Exact(SessionRevision(7)), + CommandPayload::TelemetryConfigure(_) | CommandPayload::TransportSet(_) => { + ExpectedRevision::Exact(SessionRevision(7)) + } _ => ExpectedRevision::Any, }, payload, @@ -513,6 +546,22 @@ fn counter_command_frame(payload: CommandPayload<'_>) -> Vec { bytes } +/// A `TRANSPORT_SET` to playing that also carries the retired field 2, the absolute position +/// (#1635), as a `U64` (wire code 4) of 9 in the given flag form: the frame a peer of the old build sent. +fn transport_set_with_position(mandatory: bool) -> Vec { + let mut bytes = counter_command_frame(CommandPayload::TransportSet(TransportSetRequest { + state: TransportState::Playing, + })); + bytes.extend_from_slice(&2_u16.to_le_bytes()); + bytes.extend_from_slice(&[4, u8::from(mandatory)]); + bytes.extend_from_slice(&8_u32.to_le_bytes()); + bytes.extend_from_slice(&9_u64.to_le_bytes()); + let payload_len = u32::try_from(bytes.len() - OUTER_HEADER_BYTES).expect("a short frame"); + bytes[20..24].copy_from_slice(&payload_len.to_le_bytes()); + bytes[40..44].copy_from_slice(&2_u32.to_le_bytes()); + bytes +} + /// Append the payload `AUTOMATION_ENQUEUE` carried (#1627) to an empty command frame: `count` /// (field 1, `U16`), `record_bytes` (field 2, `U16`) and `records` (field 3, `Bytes`), one valid /// 32-byte point record. The result is the complete frame the retired command had. @@ -867,7 +916,16 @@ pub enum ConformanceDecoder { /// `33e4b1cebcbfd902`: the event carries a repeated `key` message (the wire shape is the sequence /// and one nested key per ended tap), so `event.observation_ended` carries two keys. The frame /// count stays 46. -pub const COMPLETE_SCHEMA_HASH: u64 = 0xcc9a_7bcc_502c_4936; +/// #1678: an optional seventh field of the `SET_CONTROL` address message names the native effect an +/// effect-row edit was written for. The transaction's last +/// `SetControl` is an insert edit that carries the expected effect `miso.parametric-eq` (36 edits). The +/// frame count stays 46. +/// #1635 retires the stored transport position. +/// `TRANSPORT_SET` field 2, `TransportSnapshot` field 2 and `TransportStateEvent` field 3 are +/// refused and never reallocated, so `command.transport_set` carries the state alone and the +/// snapshot and event frames carry the state and the effective sample. The frame count stays 46. +/// Batch 13b carries #1678 and #1635 together and re-pins it once from `cc9a7bcc502c4936`. +pub const COMPLETE_SCHEMA_HASH: u64 = 0x0c71_96f8_6abe_6d57; /// Build every command, successful response, registered non-OK status, event, and all-opcode /// session transaction using only public typed encoder entry points. @@ -993,8 +1051,7 @@ pub fn complete_schema_corpus() -> Vec { command!( "command.transport_set", CommandPayload::TransportSet(TransportSetRequest { - state: TransportState::Playing, - position: Some(SampleTime(9)) + state: TransportState::Playing }) ); command!( @@ -1113,7 +1170,6 @@ pub fn complete_schema_corpus() -> Vec { "response.transport_get", SuccessResponsePayload::TransportGetSnapshot(TransportSnapshot { state: TransportState::Stopped, - position: SampleTime(0), effective_sample: SampleTime(0) }) ); @@ -1121,7 +1177,6 @@ pub fn complete_schema_corpus() -> Vec { "response.transport_set", SuccessResponsePayload::TransportSetSnapshot(TransportSnapshot { state: TransportState::Playing, - position: SampleTime(9), effective_sample: SampleTime(9) }) ); @@ -1231,7 +1286,6 @@ pub fn complete_schema_corpus() -> Vec { EventPayload::TransportState(TransportStateEvent { event_sequence: 1, state: TransportState::Stopped, - position: SampleTime(0), effective_sample: SampleTime(0), origin_request_id: None }) diff --git a/crates/conformance/src/randomized.rs b/crates/conformance/src/randomized.rs index 22dae38a9..0acb20285 100644 --- a/crates/conformance/src/randomized.rs +++ b/crates/conformance/src/randomized.rs @@ -1875,30 +1875,9 @@ fn draw_spans( for _ in 0..=draw.below(capacity) { let index = draw.pick(&automatable); let parameter = &descriptor.parameters[index]; - let (kind, start, end) = if parameter.automation_rate == AutomationRate::Block { - (AutomationSpanKind::Point, first, first) - } else { - let offset = if draw.chance(1, 3) { - draw.pick(&[0, frames - 1]) - } else { - draw.below(frames) - }; - let start = first + offset as u64; - let kind = draw.pick(&[ - AutomationSpanKind::Point, - AutomationSpanKind::Point, - AutomationSpanKind::Step, - AutomationSpanKind::Linear, - AutomationSpanKind::Linear, - AutomationSpanKind::Exponential, - ]); - let end = if kind == AutomationSpanKind::Point { - start - } else { - start + 1 + draw.below(2 * frames) as u64 - }; - (kind, start, end) - }; + // An automatable parameter is `Block` rate: one `Point` span at the block's first + // sample (#1636 retired the other rates and span kinds). + let start = first; // One span per parameter and start sample: two would sort as `L L R R`, which is no // longer twin pairs. if spans.iter().any(|span: &PreparedAutomationSpan| { @@ -1907,20 +1886,7 @@ fn draw_spans( continue; } let start_value = draw_value(draw, parameter); - let end_value = if kind == AutomationSpanKind::Point { - start_value - } else { - draw_value(draw, parameter) - }; - let kind = if kind == AutomationSpanKind::Exponential - && !(start_value != 0.0 - && end_value != 0.0 - && start_value.is_sign_positive() == end_value.is_sign_positive()) - { - AutomationSpanKind::Linear - } else { - kind - }; + let end_value = start_value; let channels: &[ParameterChannel] = match parameter.channel_policy { ParameterChannelPolicy::Shared => &[ParameterChannel::Both], ParameterChannelPolicy::PerLane if mono => { @@ -1936,11 +1902,11 @@ fn draw_spans( }; for channel in channels { spans.push(PreparedAutomationSpan { - kind, + kind: AutomationSpanKind::Point, channel: *channel, parameter_index: index as u32, start_sample: start, - end_sample: end, + end_sample: start, start_value, end_value, }); @@ -1978,7 +1944,7 @@ fn draw_spans( let parameter = draw.below(descriptor.parameters.len() + 1) as u32; let value = draw.pick(&[f32::NAN, f32::INFINITY, 1.0e30, -1.0e30, -0.0, 0.5]); spans.push(PreparedAutomationSpan { - kind: draw.pick(&[AutomationSpanKind::Point, AutomationSpanKind::Linear]), + kind: AutomationSpanKind::Point, channel: draw.pick(&[ ParameterChannel::Left, ParameterChannel::Right, diff --git a/crates/conformance/tests/effect_contract.rs b/crates/conformance/tests/effect_contract.rs index dc72162b8..8384f1249 100644 --- a/crates/conformance/tests/effect_contract.rs +++ b/crates/conformance/tests/effect_contract.rs @@ -23,9 +23,8 @@ use effect_contract::{ ParameterId, ParameterMapping, ParameterSmoother, PrepareEffectBankRequest, PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, PreparedPorts, PreparedSidechainPort, QualityDescriptor, RegisteredTailBound, SmoothingRule, - automation_segment_value, expected_prepared_metadata, inverse_map_normalized, - inverse_map_stepped_normalized, map_normalized, map_stepped_normalized, - validate_automation_block, validate_descriptor, + expected_prepared_metadata, inverse_map_normalized, inverse_map_stepped_normalized, + map_normalized, map_stepped_normalized, validate_automation_block, validate_descriptor, }; /// The mock's 48 kHz `Normal` entry, read from a registry that admitted it (issue #1462). @@ -143,7 +142,7 @@ fn normalized_mapping_endpoints_ties_and_round_trips_are_stable() { } #[test] -fn smoothers_and_segments_finish_on_the_exact_update_or_endpoint() { +fn smoothers_finish_on_the_exact_update() { let mut linear = ParameterSmoother::new(0.0, SmoothingRule::Linear, 4).unwrap(); assert!(linear.set_target(1.0)); assert_eq!( @@ -161,18 +160,6 @@ fn smoothers_and_segments_finish_on_the_exact_update_or_endpoint() { assert!(pole.next_value() < 1.0); } assert_eq!(pole.next_value(), 1.0); - let span = PreparedAutomationSpan { - kind: AutomationSpanKind::Linear, - channel: ParameterChannel::Left, - parameter_index: 0, - start_sample: 10, - end_sample: 14, - start_value: 2.0, - end_value: 6.0, - }; - assert_eq!(automation_segment_value(span, 10), Some(2.0)); - assert_eq!(automation_segment_value(span, 12), Some(4.0)); - assert_eq!(automation_segment_value(span, 14), Some(6.0)); } #[test] diff --git a/crates/control-plane/src/compile.rs b/crates/control-plane/src/compile.rs index e792b75df..a94323c78 100644 --- a/crates/control-plane/src/compile.rs +++ b/crates/control-plane/src/compile.rs @@ -288,7 +288,10 @@ pub(crate) fn capi_resources( cell_heap, bindings_table, bindings_transient, - } = observation_reserve(limits.maximum_control_frame_bytes, observation.slots)?; + } = observation_reserve( + limits.maximum_control_frame_bytes, + observation.reserve_slots, + )?; let maximum_configuration_items = usize::try_from(limits.maximum_control_frame_bytes) .map_err(|_| failure(ResourceFault::Platform))? / size_of::(); @@ -303,11 +306,11 @@ pub(crate) fn capi_resources( checked_layout::(2)?, // #1584 D6a: the observation-ended cells, one more than the reliable queue's slots (an // event popped for egress keeps its cell until its frame is delivered), and the heap the - // events in them own. A cell can hold the keys of any earlier plan's subscriptions, so - // the ceiling is not a function of this plan's producers (a successor that removed - // producers still holds their keys): an event must fit one control frame, which bounds - // its key count and its text (`ended_event_ceiling`). The heap is allocated when a - // transaction prepares, never at the commit. + // events in them own. A cell can hold the keys of any earlier plan's subscriptions (a + // successor that removed producers still holds their keys), so the ceiling follows the + // most slots any plan of the session has had, capped by the keys one control frame + // carries (`ended_event_ceiling`, #1676 D2), and not this plan's producers or the frame + // size. The heap is allocated when a transaction prepares, never at the commit. checked_layout::>( queue_config.reliable_event_slots.get() + 1, )?, @@ -320,8 +323,10 @@ pub(crate) fn capi_resources( checked_byte_layout(bindings_transient)?, // #1584 D6a: the newest plan's pending-subscription slots and the first slot of each // producer (one entry past the last). - checked_layout::(observation.slots)?, + checked_layout::(observation.slots)?, checked_layout::(observation.producers + 1)?, + // #1676 batch 13b fold: the per-producer admission scratch, one count per producer. + checked_layout::(observation.producers)?, checked_layout::(RENDER_DIAGNOSTIC_SLOTS)?, checked_layout::(RENDER_DIAGNOSTIC_CODE.len() * RENDER_DIAGNOSTIC_SLOTS)?, // ProtocolController and SessionControlProvider each retain their own telemetry config. @@ -448,6 +453,11 @@ pub(crate) struct ObservationResources { /// Observation slots of the plan: one per declared tap of every producer that has a queue. /// The binding table holds at most one binding per slot. pub(crate) slots: usize, + /// The slot count the ended-event cells and the binding table are charged for: the most + /// observation slots any plan of this session has had (#1676 D2). A cell holds an event + /// until its frame is delivered, so an event a larger earlier plan emitted can outlive that + /// plan, and a plan with fewer slots must still charge the room for it. + pub(crate) reserve_slots: usize, /// Effect producers of the plan. pub(crate) producers: usize, } @@ -455,6 +465,7 @@ pub(crate) struct ObservationResources { /// The observation rows of `effects`, the newest plan's producers. pub(crate) fn observation_resources( effects: &[host_core::EffectControlProducer], + slots_high_water: usize, ) -> Result { let mut slots = 0_usize; for producer in effects { @@ -465,30 +476,38 @@ pub(crate) fn observation_resources( Ok(ObservationResources { producers: effects.len(), slots, + reserve_slots: slots.max(slots_high_water), }) } -/// The smallest encoded size of one key of an `OBSERVATION_ENDED` event: two one-character IDs, -/// the section and the tap ID with their field headers, measured as what a second key adds to the -/// frame. It bounds how many keys fit one frame. -fn minimum_ended_key_bytes() -> Result { +/// How many armed taps one session can hold (#1676 D1): the keys one `OBSERVATION_ENDED` event +/// can carry in one control frame when every key is as large as the session's IDs allow (a strip +/// ID and an effect ID of [`session::STABLE_ID_MAXIMUM_BYTES`] each). A subscription past it is +/// refused before it is acknowledged, so a commit that ends every binding can always say so. +/// +/// The event's size is measured with the real codec, not estimated: the frame with one key, and +/// what a second adds. Computed on a control thread, once per plan; the session keeps the figure. +pub(crate) fn observation_binding_cap(frame_bytes: u64) -> Result { + let arithmetic = || failure(ResourceFault::Arithmetic); let codec = protocol::ProtocolCodec::default(); - let size = |ids: &[&str]| -> Option { + let id = "a".repeat(session::STABLE_ID_MAXIMUM_BYTES); + let size = |count: usize| -> Option { let event = protocol::ObservationEnded { - event_sequence: 0, - keys: ids - .iter() - .map(|id| protocol::ObservationTapKey { - strip_id: (*id).to_owned(), - section: protocol::ObservationSection::Console, - effect_id: (*id).to_owned(), - tap_id: 0, + event_sequence: u64::MAX, + // Keys are strictly ascending on the wire; the tap ID, a fixed four bytes, orders + // otherwise equal keys. + keys: (0..count) + .map(|tap| protocol::ObservationTapKey { + strip_id: id.clone(), + section: protocol::ObservationSection::Inserts, + effect_id: id.clone(), + tap_id: u32::try_from(tap).unwrap_or(u32::MAX), }) .collect(), }; match codec.encode_event_frame_into( &protocol::TypedEventFrame { - revision: protocol::SessionRevision(1), + revision: protocol::SessionRevision(u64::MAX), payload: protocol::EventPayload::ObservationEnded(&event), }, &mut [], @@ -497,12 +516,17 @@ fn minimum_ended_key_bytes() -> Result { Err(_) | Ok(_) => None, } }; - let (one, two) = (size(&["a"]), size(&["a", "b"])); - one.zip(two) - .and_then(|(one, two)| two.checked_sub(one)) + let (one, two) = ( + size(1).ok_or_else(arithmetic)?, + size(2).ok_or_else(arithmetic)?, + ); + let key = two + .checked_sub(one) .filter(|bytes| *bytes > 0) - .and_then(|bytes| u64::try_from(bytes).ok()) - .ok_or_else(|| failure(ResourceFault::Arithmetic)) + .ok_or_else(arithmetic)?; + let frame = usize::try_from(frame_bytes).map_err(|_| failure(ResourceFault::Platform))?; + // A frame too small for one key holds none: every subscription is refused. + Ok(frame.checked_sub(one).map_or(0, |room| 1 + room / key)) } /// The observation rows that are bounds, not allocations: nothing holds them until a host arms a @@ -522,14 +546,16 @@ fn observation_reserve( frame_bytes: u64, slots: usize, ) -> Result { - let ended_event = ended_event_ceiling(frame_bytes)?; + // The table never holds more bindings than a plan has slots or an event can carry (#1676). + let bindings = slots.min(observation_binding_cap(frame_bytes)?); + let ended_event = ended_event_ceiling(bindings)?; let cell_heap = ended_event .checked_mul( u64::try_from(RELIABLE_EVENT_SLOTS + 1) .map_err(|_| failure(ResourceFault::Platform))?, ) .ok_or_else(|| failure(ResourceFault::Arithmetic))?; - let (bindings_table, bindings_transient) = observation_binding_rows(slots)?; + let (bindings_table, bindings_transient) = observation_binding_rows(bindings)?; Ok(ObservationReserve { ended_event, cell_heap, @@ -562,17 +588,18 @@ pub fn observation_reserve_bytes( )) } -/// The heap one `OBSERVATION_ENDED` event can own, bounded by the control frame it must fit: its -/// text is encoded in the frame, so it is at most the frame; its key count is at most the frame -/// over the smallest key, and each key is a `protocol::ObservationTapKey` (#1584 D6a). The bound -/// does not depend on any plan's producers: a cell can hold the keys of an earlier plan. -pub(crate) fn ended_event_ceiling(frame_bytes: u64) -> Result { - let keys = frame_bytes / minimum_ended_key_bytes()?; - u64::try_from(size_of::()) - .ok() - .and_then(|key| key.checked_mul(keys)) - .and_then(|table| table.checked_add(frame_bytes)) - .ok_or_else(|| failure(ResourceFault::Arithmetic)) +/// The heap one `OBSERVATION_ENDED` event can own when it ends at most `bindings` taps: its key +/// list, one `protocol::ObservationTapKey` each, and the text of the two IDs every key owns +/// (#1584 D6a, #1676 D2). The count is what the session can hold, the smaller of the plan's +/// observation slots and the cap one frame allows, never the frame size itself. It does not +/// depend on any plan's producers: a cell can hold the keys of an earlier plan. +pub(crate) fn ended_event_ceiling(bindings: usize) -> Result { + let arithmetic = || failure(ResourceFault::Arithmetic); + let key = size_of::() + .checked_add(2 * session::STABLE_ID_MAXIMUM_BYTES) + .ok_or_else(arithmetic)?; + let heap = bindings.checked_mul(key).ok_or_else(arithmetic)?; + u64::try_from(heap).map_err(|_| failure(ResourceFault::Platform)) } /// What the binding table and a rebuild's transient copies of it hold for a plan with `slots` @@ -877,6 +904,7 @@ pub(crate) fn prepare_runtime( limits: ControlLimits, successor: Option>, superseded: &host_core::LiveValuesSuperseded, + observation_slots_high_water: usize, ) -> Result { let caps = prepare_caps(limits); // Shape still runs before host/runtime allocation. The exact provider catalog exists only @@ -902,13 +930,25 @@ pub(crate) fn prepare_runtime( track_count, effect_controls, route_controls, + jump_lengths, .. } = handles; + // #1639 D1: every prepared plan has a jump-length cell, so a handle set without its writer is + // a preparation failure, with the code and stage of the cell's own attach failure; the epoch + // then holds the writer unconditionally. + let jump_lengths = jump_lengths.ok_or_else(|| { + prepare_failure(PrepareDiagnostics::fixed( + host_core::PrepareRejection::Graph, + "host.jump_lengths.attach", + )) + })?; let strips = StripLanes { controls: strip_controls.into_boxed_slice(), track_count, // #1557 D4: the send producers, at exactly one entry per route lane the plan attached. routes: route_controls.into_boxed_slice(), + // #1639 D1: the plan's jump-length cell writer. + jump_lengths, }; // #1263 D2: likewise one entry per effect instance, built at exactly that capacity, so the // boxed slice is the allocation host-core walked for `effect_control_resources`. @@ -919,7 +959,7 @@ pub(crate) fn prepare_runtime( &prepared.inventory, prepared.report.effect_control_resources, prepared.report.route_control_resources, - observation_resources(&effects)?, + observation_resources(&effects, observation_slots_high_water)?, limits, )?; if capi.active_retained > limits.maximum_control_retained_bytes @@ -1033,6 +1073,7 @@ where limits, None, &live_values_superseded, + 0, )?; let control_bytes = usize::try_from(limits.maximum_control_frame_bytes) @@ -1161,10 +1202,20 @@ where observation: controls::ObservationBindings::new(), observation_pending: Box::default(), observation_offsets: Box::default(), + observation_batch_counts: Box::default(), + observation_batch_mark: 0, + observation_armed: 0, + observation_cap: observation_binding_cap(limits.maximum_control_frame_bytes)?, + observation_slots_high_water: 0, }; - (session.observation_offsets, session.observation_pending) = - crate::control::observation_slots(&session.providers.effects) + let (offsets, pending, counts, armed) = + crate::control::observation_slots(&session.providers.effects, &session.observation) .map_err(|_| failure(ResourceFault::Allocation))?; + session.observation_offsets = offsets; + session.observation_pending = pending; + session.observation_batch_counts = counts; + session.observation_armed = armed; + session.observation_slots_high_water = session.observation_pending.len(); Ok(CompiledChildren { session, plan: PlanState::new(owner, shared), diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index 2085bd1dc..c543d6daa 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -19,6 +19,9 @@ pub(crate) struct StripLanes { /// One producer per route into a submix, in canonical route-ID order; empty when the plan /// carries no route lane. pub(crate) routes: Box<[host_core::RouteControlProducer]>, + /// The writer of the plan's jump-length cell (#1639 D1), held inline; every prepared plan has + /// one, and preparation refuses a handle set without it. + pub(crate) jump_lengths: host_core::GraphJumpLengthProducer, } /// One epoch's worth of host-owned source producers, strip live-control producers and effect @@ -413,27 +416,178 @@ pub struct SessionState { /// acknowledged survives a rebuild that restarts its effect. pub(crate) observation: controls::ObservationBindings, /// The subscription state each observation slot of the newest plan was last pushed, waiting - /// to be folded into [`Self::observation`] (#1584 D6a). The browser admits a subscription - /// inside `process()`, where nothing may allocate, and a binding owns its key's text, so the + /// to be folded into [`Self::observation`] (#1584 D6a). The browser admits a subscription in + /// the worklet's message handler between render quanta, on the audio thread, where nothing may + /// allocate, and a binding owns its key's text, so the /// push records the absolute state here, in storage sized when the plan is published, and the /// next service step folds it into the table. A rebuild folds first: it only starts from /// [`Self::command`], which services. - pub(crate) observation_pending: Box<[PendingArm]>, + pub(crate) observation_pending: Box<[ObservationSlot]>, /// For each position of the newest plan's effect producers, the first of its slots in /// `observation_pending`, and one entry past the last: a producer's slots are /// `offsets[position]..offsets[position + 1]`, one per declared tap in declaration order. pub(crate) observation_offsets: Box<[u32]>, + /// How many of [`Self::observation_pending`]'s slots are armed now, pushes not yet folded + /// included: the figure a subscription is admitted against (#1676 D1). Never above + /// [`Self::observation_cap`]. + pub(crate) observation_armed: usize, + /// The most taps the host may have armed at once: the keys one `OBSERVATION_ENDED` event can + /// carry in one control frame, so the commit that ends every binding can always say so + /// (#1676 D1). Fixed by the session's frame limit when it compiles. + pub(crate) observation_cap: usize, + /// For each position of the newest plan's effect producers, the records of the batch being + /// admitted that name it (#1676, batch 13b fold): the room check reads one count per producer + /// instead of counting the batch again for each record. Sized with + /// [`Self::observation_offsets`], when the plan's slots are built, so admission allocates + /// nothing. + pub(crate) observation_batch_counts: Box<[ObservationBatchCount]>, + /// The stamp of the batch being admitted: a slot's or a producer count's `mark` equal to it + /// belongs to this batch, any other value to an earlier one, so the scratch needs no clearing + /// between batches. + pub(crate) observation_batch_mark: u32, + /// The most observation slots any committed plan has had; the ended-event cells and the + /// binding table are charged for it (#1676 D2). + pub(crate) observation_slots_high_water: usize, } -/// A plan's observation slot offsets and its pending-subscription storage. -pub(crate) type ObservationSlots = (Box<[u32]>, Box<[PendingArm]>); +/// The stable key of one observation tap, borrowed from the bytes that name it (#1677): the strip's +/// ID, the rack, the console slot's or insert's ID and the effect-local tap ID. The same key +/// `OBSERVATION_ENDED` names ([`controls::TapKey`]), in a form a host can build from its staging +/// without allocating. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ObservationKeyRef<'a> { + /// The ID of the track or submix. + pub strip_id: &'a [u8], + /// The console slot or the insert section. + pub rack: host_core::LiveEffectRack, + /// The console slot's or the insert's ID. + pub effect_id: &'a [u8], + /// The effect-local tap ID. + pub tap_id: u32, +} + +/// Where [`SessionState::resolve_observation_key`] found a tap, on the newest plan. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ResolvedObservationTap { + /// The position of the tap's effect in [`SessionState::effect_producers`]. + pub producer: usize, + /// The tap's index in its effect's declared menu, which the render side arms. + pub tap_index: u32, +} + +/// Why a key names no tap of the newest plan (#1677). Nothing was written. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ObservationKeyError { + /// The strip ID is no track or submix of the plan. + UnknownStrip, + /// The strip has no console slot or insert of that ID. + UnknownEffect, + /// The effect declares no tap of that ID. + UnknownTap, + /// The tap is declared and its cost is not resident; V1 binds no lane for one. + ComputedTap, +} + +/// A subscription batch would leave more taps armed than one `OBSERVATION_ENDED` event can name in +/// one control frame (#1676 D1). Nothing was written. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ObservationLimit { + /// The position, in the batch, of the first change that adds a tap past the cap. + pub index: usize, + /// The most taps the session may have armed at once. + pub cap: usize, +} + +/// One subscription change of a batch handed to +/// [`SessionState::admit_and_push_observation_batch`] (#1676). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ObservationChange { + /// The producer's position in [`SessionState::effect_producers`]. + pub position: usize, + /// The tap's index in the producer's observation menu. + pub tap_index: u32, + /// `true` arms the tap, `false` disarms it. + pub armed: bool, + /// Render blocks per published window; ignored by a disarm. + pub window_blocks: u32, +} -/// The pending-subscription storage for `producers`, the newest plan's: the first slot of each -/// producer (and one past the last), and one slot per declared tap (#1584 D6a). Built when a plan -/// is prepared, fallibly and before anything commits; the session takes it after the commit and -/// after the fold, so nothing pending is lost to a change of positions. +/// Why [`SessionState::admit_and_push_observation_batch`] refused a batch. Nothing was queued. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ObservationRefusal { + /// The batch would leave more taps armed than the cap. Not retryable as sent: a tap must be + /// disarmed first. + Limit(ObservationLimit), + /// The change at `index` needs more room than its producer's observation queue has free. + /// Retryable once a render drains the queue. + Full { + /// The change's position in the batch. + index: usize, + }, + /// The change at `index` names a position past [`SessionState::effect_producers`]. + NoProducer { + /// The change's position in the batch. + index: usize, + }, + /// The change at `index` names a tap an earlier change of the same batch already names. A + /// change sets its tap's whole state and the batch drains in one block, so a second change + /// would supersede the first, which an observation record never is (D15-2). Not retryable as + /// sent. + Duplicate { + /// The position in the batch of the second change that names the tap. + index: usize, + }, + /// The change at `index` names a producer whose observation queue has no slot for its tap: + /// the tap index is past the producer's observation menu, or the plan bound the producer no + /// observation queue at all (capacity `0`). Such a record would be acknowledged and never + /// applied, so it is refused before anything is queued. Not retryable as sent. + Unbound { + /// The change's position in the batch. + index: usize, + }, +} + +/// The index in `descriptor`'s observation menu of the tap with ID `tap_id`, the one the render side +/// arms (#1677). A tap the menu does not declare is [`ObservationKeyError::UnknownTap`], and a +/// declared tap that is not resident is [`ObservationKeyError::ComputedTap`]: V1 ships no +/// implementation for one, and saying so is better than binding a lane that would never publish. +/// +/// # Errors +/// +/// As above. +pub fn resolve_observation_tap( + descriptor: &effect_contract::EffectDescriptor, + tap_id: u32, +) -> Result { + let (index, tap) = descriptor + .observations + .iter() + .enumerate() + .find(|(_, tap)| tap.id.0 == tap_id) + .ok_or(ObservationKeyError::UnknownTap)?; + if !matches!(tap.cost, effect_contract::ObservationCost::Resident) { + return Err(ObservationKeyError::ComputedTap); + } + u32::try_from(index).map_err(|_| ObservationKeyError::UnknownTap) +} + +/// A plan's observation slot offsets, its slot storage, its per-producer admission scratch and how +/// many of its slots are armed. +pub(crate) type ObservationSlots = ( + Box<[u32]>, + Box<[ObservationSlot]>, + Box<[ObservationBatchCount]>, + usize, +); + +/// The slot storage for `producers`, the newest plan's: the first slot of each producer (and one +/// past the last), and one slot per declared tap (#1584 D6a), each armed when `bindings` holds its +/// tap (#1676). Built when a plan is prepared, fallibly and before anything commits; the session +/// takes it after the commit and after the fold, so nothing pending is lost to a change of +/// positions. pub(crate) fn observation_slots( producers: &[host_core::EffectControlProducer], + bindings: &controls::ObservationBindings, ) -> Result { let mut offsets = Vec::new(); offsets.try_reserve_exact(producers.len() + 1)?; @@ -444,10 +598,68 @@ pub(crate) fn observation_slots( total.saturating_add(u32::try_from(producer.observe_capacity()).unwrap_or(u32::MAX)); } offsets.push(total); - let mut pending = Vec::new(); - pending.try_reserve_exact(total as usize)?; - pending.resize(total as usize, PendingArm::None); - Ok((offsets.into_boxed_slice(), pending.into_boxed_slice())) + let mut counts = Vec::new(); + counts.try_reserve_exact(producers.len())?; + counts.resize(producers.len(), ObservationBatchCount::default()); + let mut slots = Vec::new(); + slots.try_reserve_exact(total as usize)?; + let mut armed = 0_usize; + if bindings.is_empty() { + slots.resize(total as usize, ObservationSlot::default()); + } else { + for producer in producers { + for tap in 0..producer.observe_capacity() { + let is_armed = producer + .descriptor + .observations + .get(tap) + .is_some_and(|declared| { + bindings.is_armed_by_parts( + &producer.track_id, + producer.address.rack == host_core::LiveEffectRack::Console, + &producer.effect_id, + declared.id.0, + ) + }); + armed += usize::from(is_armed); + slots.push(ObservationSlot { + armed: is_armed, + pending: PendingArm::None, + mark: 0, + }); + } + } + } + Ok(( + offsets.into_boxed_slice(), + slots.into_boxed_slice(), + counts.into_boxed_slice(), + armed, + )) +} + +/// One producer's share of the batch being admitted (#1676, batch 13b fold): how many of its +/// records name the producer, valid while `mark` is the session's batch stamp. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct ObservationBatchCount { + /// The batch stamp `needed` was counted under. + pub(crate) mark: u32, + /// Records of that batch that name the producer. + pub(crate) needed: u32, +} + +/// One observation slot: whether its tap is armed now, and what the host pushed to it since the +/// last fold (#1584 D6a, #1676). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct ObservationSlot { + /// Armed now: the table's state with every push since folded in. Lets a subscription be + /// admitted against the cap without a key lookup, on the audio thread between quanta. + pub(crate) armed: bool, + /// The push waiting to be folded into the table. + pub(crate) pending: PendingArm, + /// The stamp of the last batch that named this slot (#1676, batch 13b fold): a change whose + /// slot already carries the current stamp is the batch's second naming of the tap. + pub(crate) mark: u32, } /// What the host last pushed to one observation slot (#1584 D6a). @@ -1470,6 +1682,7 @@ where observation: &observation_change.bindings, }), &self.live_values_superseded, + self.observation_slots_high_water, ) .map_err(CommandError::CompileRejected)?; let PreparedRuntime { @@ -1601,7 +1814,10 @@ where // #1584 D6a: the newest plan's pending-subscription storage is built here, with // the other fallible steps, so no allocation follows the commit; the commit then // only assigns it. A refusal drops what was built and changes nothing. - let slots = match observation_slots(&candidate_provider.effects) { + let slots = match observation_slots( + &candidate_provider.effects, + &observation_change.bindings, + ) { Ok(slots) => slots, Err(_) => { drop(reports); @@ -1647,7 +1863,15 @@ where self.pending_providers.push(candidate_provider); // #1584 D6a: the newest plan's slots, built above. The fold ran when this command // was serviced and nothing has been pushed since (one thread), so none is lost. - (self.observation_offsets, self.observation_pending) = slots; + ( + self.observation_offsets, + self.observation_pending, + self.observation_batch_counts, + self.observation_armed, + ) = slots; + self.observation_slots_high_water = self + .observation_slots_high_water + .max(self.observation_pending.len()); reports.push((epoch, A::Row::from(resources))); reservation.commit(); #[cfg(feature = "test-support")] @@ -1888,6 +2112,9 @@ where }; resolved_routes.push(index); } + // 2a'. A delta with new jump lengths writes them to the newest plan's cell (#1639 D3), + // which every epoch holds (D1). + let jump_lane = &mut strips.jump_lengths; // 2b. A strip with input records has an input writer, and every filter target passes the // contract the writer assumes (#1556 D5). Both are checked before the first write: the @@ -2042,6 +2269,10 @@ where for (route, &index) in delta.routes.iter().zip(&resolved_routes) { route_lanes[index].write(stamp, route.record); } + // #1639 D3: the new jump lengths, in the same block as the records. + if let Some(lengths) = delta.jump_lengths { + jump_lane.write(stamp, lengths); + } for (instance, &index) in delta.effects.iter().zip(&resolved_effects) { let producer = &mut owners.effects[index]; match &instance.targets { @@ -2379,8 +2610,9 @@ where /// The newest plan's effect producers, in `HostLiveControlHandles::effect_controls` order /// (#1381a), for reading: each producer's identity and descriptor, and the free room of its /// observation queue. The adapter reaches an effect's observation queue for the browser's - /// observe kinds 7 and 8 (ruling C9) through [`Self::push_effect_observe`], and nothing - /// else of a producer, so it cannot write a live value or open an owner behind the commit. + /// observe kinds 7 and 8 (ruling C9) through [`Self::admit_and_push_observation_batch`], and + /// nothing else of a producer, so it cannot write a live value or open an owner behind the + /// commit. /// /// These are the producers of the newest committed plan (#1290a, batch 10 review): the /// pending candidate's while one waits for its swap, else the current plan's. That is the plan @@ -2393,13 +2625,255 @@ where &self.newest_providers().effects } + /// Resolves the stable key of an observation tap on the newest committed plan (#1677): the + /// plan [`Self::effect_producers`] lists, the pending candidate's while one waits for its swap. + /// A subscribe or unsubscribe names its tap by this key and no position of any plan, so a + /// rebuild between a client's last read and the record cannot make it arm another effect's + /// tap. The browser's record calls it, and the future C ABI observe edit (#1584) must call it. + /// + /// The strip is looked up in the committed model, so a strip with no effect is still an + /// unknown effect and not an unknown strip. The effect is the producer of the strip's console + /// slot or insert of that ID. The comparison is by bytes and the call allocates nothing: the + /// browser admits a record in the worklet's message handler, on the audio thread between + /// render quanta. + /// + /// # Errors + /// + /// The first of [`ObservationKeyError`] that applies, in the order strip, effect, tap. Nothing + /// is written. + pub fn resolve_observation_key( + &self, + key: &ObservationKeyRef<'_>, + ) -> Result { + let model = self.committed_session().normalized_model(); + let strip_known = model + .tracks + .binary_search_by(|track| track.id.as_str().as_bytes().cmp(key.strip_id)) + .is_ok() + || model + .submixes + .binary_search_by(|submix| submix.id.as_str().as_bytes().cmp(key.strip_id)) + .is_ok(); + if !strip_known { + return Err(ObservationKeyError::UnknownStrip); + } + let (producer, found) = self + .newest_providers() + .effects + .iter() + .enumerate() + .find(|(_, producer)| { + producer.address.rack == key.rack + && producer.track_id.as_bytes() == key.strip_id + && producer.effect_id.as_bytes() == key.effect_id + }) + .ok_or(ObservationKeyError::UnknownEffect)?; + Ok(ResolvedObservationTap { + producer, + tap_index: resolve_observation_tap(found.descriptor, key.tap_id)?, + }) + } + + /// The most taps the host may have armed at once (#1676 D1): the keys one `OBSERVATION_ENDED` + /// event carries in one control frame with every ID at its longest. Fixed when the session + /// compiles, from its control frame limit. + #[must_use] + pub fn observation_cap(&self) -> usize { + self.observation_cap + } + + /// How many taps are armed now, pushes the table has not folded yet included. + #[must_use] + pub fn observation_armed(&self) -> usize { + self.observation_armed + } + + /// Admits a batch of subscription changes, then queues it: the one way a host arms or disarms + /// an observation tap (#1676 D1, attempt 2). A change names the producer's position in + /// [`Self::effect_producers`] and the tap, as [`Self::resolve_observation_key`] returns them. + /// + /// Three checks run before anything is written, in this order, and a refusal names the first + /// change in the iterator's order that breaks the first check that fails: + /// + /// 1. **A slot per tap, and one naming per tap.** A change whose producer has no observation + /// slot for its tap (a tap index past the menu, or a producer with no observation queue) is + /// [`ObservationRefusal::Unbound`]. A change that names a tap an earlier change of the + /// batch named is [`ObservationRefusal::Duplicate`], at the second naming. The cap below counts each + /// tap once, so it is only sound for a batch that names each tap once. + /// 2. **The cap.** The batch is judged by the state it leaves: arms of taps that are not + /// armed add, disarms of armed taps remove, and the rest change nothing. A batch that would + /// leave more than [`Self::observation_cap`] taps armed is + /// [`ObservationRefusal::Limit`], at the arm that crosses after every removal of the batch + /// is counted. A retry of the same batch never succeeds until another tap is disarmed. + /// 3. **The room.** Each producer's observation queue holds one record per declared tap until + /// a render drains it; a batch that needs more than the free room of a producer is + /// [`ObservationRefusal::Full`] (retryable after a render), and a position past the list is + /// [`ObservationRefusal::NoProducer`]. These two are one check: the first change in order + /// that names no producer or a producer without the room the batch needs is refused. + /// + /// Then every change is queued. A refusal writes nothing observable, so it is whole and + /// precedes every acknowledgement. The call is linear in the batch, reads the per-slot state + /// the pushes keep and per-producer scratch sized with the plan, and allocates nothing, so it + /// runs in the browser worklet's message handler, on the audio thread between render quanta. + /// + /// # Errors + /// + /// [`ObservationRefusal`], as above. Nothing was queued and no state moved. + /// + /// # Panics + /// + /// Never in practice: the room check covers every push of the batch on the one control thread, + /// so a push that is refused after it is an engine fault, which would break the whole-batch + /// promise and is not returned as a typed refusal. + pub fn admit_and_push_observation_batch( + &mut self, + changes: impl Iterator + Clone, + ) -> Result<(), ObservationRefusal> { + let mark = self.next_observation_batch_mark(); + // Pass 1: a tap the producer has no slot for, the second naming of a tap, and each + // producer's record count for pass 3. A position past the list is pass 3's `NoProducer`. + for (index, change) in changes.clone().enumerate() { + if change.position < self.newest_providers().effects.len() + && self + .observation_slot(change.position, change.tap_index) + .is_none() + { + return Err(ObservationRefusal::Unbound { index }); + } + if let Some(count) = self.observation_batch_counts.get_mut(change.position) { + if count.mark != mark { + *count = ObservationBatchCount { mark, needed: 0 }; + } + count.needed = count.needed.saturating_add(1); + } + if let Some(slot) = self.observation_slot(change.position, change.tap_index) { + let entry = &mut self.observation_pending[slot]; + if entry.mark == mark { + return Err(ObservationRefusal::Duplicate { index }); + } + entry.mark = mark; + } + } + // Pass 2: the cap. + self.admit_observation_changes(changes.clone()) + .map_err(ObservationRefusal::Limit)?; + // Pass 3: the room, one count per producer from pass 1. + let producers = &self.newest_providers().effects; + for (index, change) in changes.clone().enumerate() { + let Some(producer) = producers.get(change.position) else { + return Err(ObservationRefusal::NoProducer { index }); + }; + let needed = self + .observation_batch_counts + .get(change.position) + .filter(|count| count.mark == mark) + .map_or(0, |count| count.needed as usize); + if needed > producer.observe_available_capacity() { + return Err(ObservationRefusal::Full { index }); + } + } + for change in changes { + self.push_effect_observe( + change.position, + change.tap_index, + change.armed, + change.window_blocks, + ) + .expect("the room check found the producer") + .expect("the room check found room for every record of the batch"); + } + debug_assert!( + self.observation_armed <= self.observation_cap, + "an admitted batch leaves {} taps armed past the cap {}", + self.observation_armed, + self.observation_cap + ); + Ok(()) + } + + /// A fresh stamp for the batch about to be admitted. A stamp of `0` is what new scratch + /// holds, so it is never handed out; when the counter wraps, every mark is cleared first (a + /// bounded pass over storage sized with the plan, which allocates nothing). + fn next_observation_batch_mark(&mut self) -> u32 { + self.observation_batch_mark = self.observation_batch_mark.wrapping_add(1); + if self.observation_batch_mark == 0 { + for slot in &mut self.observation_pending { + slot.mark = 0; + } + for count in &mut self.observation_batch_counts { + *count = ObservationBatchCount::default(); + } + self.observation_batch_mark = 1; + } + self.observation_batch_mark + } + + /// The index in [`Self::observation_pending`] of the tap at `tap_index` of the producer at + /// `position`, if both name one. + fn observation_slot(&self, position: usize, tap_index: u32) -> Option { + let first = *self.observation_offsets.get(position)?; + let end = *self.observation_offsets.get(position + 1)?; + let slot = first.checked_add(tap_index).filter(|slot| *slot < end)?; + Some(slot as usize).filter(|slot| *slot < self.observation_pending.len()) + } + + /// The cap check of [`Self::admit_and_push_observation_batch`]: moves nothing. Sound only + /// for a batch that names each tap once, which the caller checks first. + fn admit_observation_changes( + &self, + changes: impl Iterator + Clone, + ) -> Result<(), ObservationLimit> { + let state = |position: usize, tap_index: u32| -> Option { + Some(self.observation_pending[self.observation_slot(position, tap_index)?].armed) + }; + let removed = changes + .clone() + .filter(|change| { + !change.armed && state(change.position, change.tap_index) == Some(true) + }) + .count(); + let mut armed_after = self.observation_armed.saturating_sub(removed); + for (index, change) in changes.enumerate() { + if change.armed && state(change.position, change.tap_index) == Some(false) { + armed_after += 1; + if armed_after > self.observation_cap { + return Err(ObservationLimit { + index, + cap: self.observation_cap, + }); + } + } + } + Ok(()) + } + + /// Queues one observation record past every admission check, the over-frame defence's only + /// way in (#1676 attempt 2): a test arms more taps than the cap to prove that a commit whose + /// ended event would not fit one frame is still refused. + /// + /// # Errors + /// + /// As the queue push: `None` for a position past the list, else the producer's refusal. + #[cfg(feature = "test-support")] + pub fn test_push_observe_unadmitted( + &mut self, + position: usize, + tap_index: u32, + armed: bool, + window_blocks: u32, + ) -> Option> { + self.push_effect_observe(position, tap_index, armed, window_blocks) + } + /// Queues one observation record on the newest plan's effect producer at `position` of - /// [`Self::effect_producers`]: the only write the adapter makes to a producer. + /// [`Self::effect_producers`]. Private to the crate: a host reaches it only through + /// [`Self::admit_and_push_observation_batch`], so no record is queued without the cap check + /// (#1676 attempt 2). /// /// # Errors /// /// `None` for a position past the list, else the producer's refusal (no queue, or full). - pub fn push_effect_observe( + pub(crate) fn push_effect_observe( &mut self, position: usize, tap_index: u32, @@ -2416,8 +2890,8 @@ where if pushed.is_ok() { // #1584 D6a: the push is the acknowledgement, so the state every later successor is // prepared from is recorded with it, after the only fallible step and without - // allocating: this runs inside the browser's `process()`. A record the lane refused - // records nothing. The next service step folds it into the table. + // allocating: this runs on the browser's audio thread, between quanta. A record the + // lane refused records nothing. The next service step folds it into the table. let slots = self .observation_offsets .get(position) @@ -2425,13 +2899,21 @@ where .zip(self.observation_offsets.get(position + 1).copied()); if let Some((first, end)) = slots && let Some(slot) = first.checked_add(tap_index).filter(|slot| *slot < end) - && let Some(pending) = self.observation_pending.get_mut(slot as usize) + && let Some(entry) = self.observation_pending.get_mut(slot as usize) { - *pending = if armed { + entry.pending = if armed { PendingArm::Arm(window_blocks) } else { PendingArm::Disarm }; + // The count follows the slot's state (#1676): an arm of an armed tap only changes + // its window, and a disarm of a tap that is not armed removes nothing. + match (entry.armed, armed) { + (false, true) => self.observation_armed += 1, + (true, false) => self.observation_armed -= 1, + _ => {} + } + entry.armed = armed; } } Some(pushed) @@ -2443,7 +2925,7 @@ where if self .observation_pending .iter() - .all(|arm| *arm == PendingArm::None) + .all(|slot| slot.pending == PendingArm::None) { return; } @@ -2460,7 +2942,7 @@ where break; }; for slot in first..end { - let arm = core::mem::take(&mut self.observation_pending[slot as usize]); + let arm = core::mem::take(&mut self.observation_pending[slot as usize].pending); let Some(key) = tap_key_of(producer, slot - first) else { continue; }; diff --git a/crates/control-plane/src/lib.rs b/crates/control-plane/src/lib.rs index 5d5c1683d..9e6038e98 100644 --- a/crates/control-plane/src/lib.rs +++ b/crates/control-plane/src/lib.rs @@ -61,7 +61,9 @@ mod plan; pub use adapter::{ControlAdapter, ControlLimits, PlanResources}; pub use compile::{CompiledChildren, RuntimePreparer, compile_children}; pub use control::{ - CommandError, EventError, EventLane, REPREPARE_REQUIRED, SessionState, SourceFailure, + CommandError, EventError, EventLane, ObservationChange, ObservationKeyError, ObservationKeyRef, + ObservationLimit, ObservationRefusal, REPREPARE_REQUIRED, ResolvedObservationTap, SessionState, + SourceFailure, resolve_observation_tap, }; pub use error::{CompileFailure, ResourceFault}; /// One planar chunk a host submits to a source of the session ([`SessionState::submit`]). diff --git a/crates/control-plane/tests/observation_bindings.rs b/crates/control-plane/tests/observation_bindings.rs index 8e87346cf..56e6aa141 100644 --- a/crates/control-plane/tests/observation_bindings.rs +++ b/crates/control-plane/tests/observation_bindings.rs @@ -9,13 +9,15 @@ use control_plane::{ CommandError, CompileFailure, CompiledChildren, ControlAdapter, ControlLimits, EventLane, - PlanResources, PlanState, ResourceFault, RuntimePreparer, SessionState, compile_children, + ObservationChange, ObservationKeyError, ObservationKeyRef, ObservationLimit, + ObservationRefusal, PlanResources, PlanState, ResolvedObservationTap, ResourceFault, + RuntimePreparer, SessionState, compile_children, }; use controls::{TapKey, TapSection}; use host_core::{ CompiledSession, HostLiveControlHandles, HostLiveControlRequest, HostLiveLanes, - HostPrepareCaps, LiveEffectAddress, LiveValuesSuperseded, PrepareDiagnostics, PreparedHost, - SuccessorBase, prepare_host_runtime_with_live_lanes, + HostPrepareCaps, LiveEffectAddress, LiveEffectRack, LiveValuesSuperseded, PrepareDiagnostics, + PreparedHost, SuccessorBase, prepare_host_runtime_with_live_lanes, prepare_host_runtime_with_live_lanes_successor, }; use protocol::{ @@ -107,8 +109,12 @@ impl Rig { } fn with_limits(limits: ControlLimits) -> Self { + Self::with_session(SESSION, limits) + } + + fn with_session(document: &str, limits: ControlLimits) -> Self { let CompiledChildren { session, plan } = - compile_children::(SESSION, limits, Box::new(ObservingPreparer)) + compile_children::(document, limits, Box::new(ObservingPreparer)) .expect("boot compile"); Self { session, @@ -159,18 +165,23 @@ impl Rig { /// Arm tap 0 (gain reduction) of `strip`'s `comp` insert, as the browser's kind 7 does. fn subscribe(&mut self, strip: &str) { let position = self.position(strip); - self.session - .push_effect_observe(position, 0, true, WINDOW_BLOCKS) - .expect("a producer") - .expect("room in the queue"); + self.observe(position, true).expect("admitted"); } fn unsubscribe(&mut self, strip: &str) { let position = self.position(strip); + self.observe(position, false).expect("admitted"); + } + + /// Arm or disarm tap 0 of the producer at `position`, as a batch of one: the one way a host + /// queues an observation record (#1676). + fn observe(&mut self, position: usize, armed: bool) -> Result<(), ObservationRefusal> { + self.batch(&[change(position, armed)]) + } + + fn batch(&mut self, changes: &[ObservationChange]) -> Result<(), ObservationRefusal> { self.session - .push_effect_observe(position, 0, false, 0) - .expect("a producer") - .expect("room in the queue"); + .admit_and_push_observation_batch(changes.iter().copied()) } fn position(&self, strip: &str) -> usize { @@ -215,6 +226,16 @@ impl Rig { } } +/// Arm (with the test's window) or disarm tap 0 of the producer at `position`. +fn change(position: usize, armed: bool) -> ObservationChange { + ObservationChange { + position, + tap_index: 0, + armed, + window_blocks: if armed { WINDOW_BLOCKS } else { 0 }, + } +} + fn key(strip: &str) -> TapKey { TapKey { strip: StableId::parse(strip).expect("strip"), @@ -277,6 +298,47 @@ fn committed(events: &[(u64, DecodedEventPayload<'static>)]) -> usize { .count() } +/// A change whose producer has no observation slot for its tap is refused as `Unbound` before +/// anything is queued, ahead of the room check, and the same producer's real tap is still admitted +/// afterwards. +/// +/// Red if the core admits a tap index past the producer's observation menu: the record would be +/// queued and acknowledged, and the render side would never apply it. +#[test] +fn a_change_for_a_tap_with_no_slot_is_refused_and_queues_nothing() { + let mut rig = Rig::new(); + let position = rig.position("comp1"); + let capacity = rig.session.effect_producers()[position].observe_capacity(); + let free = rig.session.effect_producers()[position].observe_available_capacity(); + let past = ObservationChange { + tap_index: u32::try_from(capacity).expect("a small menu"), + ..change(position, true) + }; + assert_eq!( + rig.batch(&[past]), + Err(ObservationRefusal::Unbound { index: 0 }), + "the tap past the menu" + ); + assert_eq!( + rig.batch(&[change(position, true), past]), + Err(ObservationRefusal::Unbound { index: 1 }), + "at its own index, before the room check" + ); + assert_eq!( + rig.session.effect_producers()[position].observe_available_capacity(), + free, + "a refusal queues nothing" + ); + assert_eq!( + rig.session.observation_armed(), + 0, + "a refusal moves nothing" + ); + assert!(rig.session.observation_bindings().is_empty()); + rig.observe(position, true) + .expect("the real tap is admitted"); +} + /// The push is the acknowledgement: it writes the table with it, a disarm clears it, and a push /// the lane refuses writes nothing. /// @@ -294,11 +356,12 @@ fn a_push_writes_the_table_and_a_refused_push_writes_nothing() { // The queue holds one slot per declared tap, so a disarm before any render is refused, and // the binding stays: the table never says less than the lane was told. let position = rig.position("comp1"); - let refused = rig - .session - .push_effect_observe(position, 0, false, 0) - .expect("a producer"); - assert!(refused.is_err(), "the queue has no room for the disarm"); + let refused = rig.observe(position, false); + assert_eq!( + refused, + Err(ObservationRefusal::Full { index: 0 }), + "the queue has no room for the disarm" + ); assert_eq!(rig.session.observation_bindings().len(), 2); // After a render drained it, the disarm is accepted and clears the binding. rig.render(); @@ -513,10 +576,7 @@ fn a_strip_removed_with_many_subscribed_inserts_gives_one_event_with_every_key() .iter() .position(|producer| &*producer.track_id == "comp1" && &*producer.effect_id == id) .expect("the insert has a producer"); - rig.session - .push_effect_observe(position, 0, true, WINDOW_BLOCKS) - .expect("a producer") - .expect("room in the queue"); + rig.observe(position, true).expect("under the cap"); } assert_eq!(rig.session.observation_bindings().len(), ids.len()); let before = rig.revision(); @@ -558,7 +618,10 @@ fn a_strip_removed_with_many_subscribed_inserts_gives_one_event_with_every_key() /// that would end that many subscriptions is refused as a resource limit (not retryable /// backpressure) before anything changes: the revision, the canonical session, the bindings, the /// plan epoch and the event lane are as they were. A strip with two subscribed inserts (an end -/// that fits) commits under the same small limit. +/// that fits) commits under the same small limit. This is the defensive path: a subscription +/// is admitted against the cap (#1676), so a valid removal never reaches it; the test pushes +/// past the cap on purpose, through the test-only `test_push_observe_unadmitted`, the one way +/// past admission. /// /// Red if the commit goes ahead and drops the end (an acked removal whose subscribers never hear), /// if the refusal is backpressure (the host would retry forever), or if the refusal leaves the @@ -612,8 +675,9 @@ fn an_end_event_over_one_control_frame_refuses_the_removal_and_changes_nothing() .iter() .position(|producer| &*producer.track_id == "comp1" && &*producer.effect_id == id) .expect("the insert has a producer"); + // Past the cap on purpose: the defence below is only reachable without admission. rig.session - .push_effect_observe(position, 0, true, WINDOW_BLOCKS) + .test_push_observe_unadmitted(position, 0, true, WINDOW_BLOCKS) .expect("a producer") .expect("room in the queue"); } @@ -654,3 +718,526 @@ fn an_end_event_over_one_control_frame_refuses_the_removal_and_changes_nothing() ); assert!(rig.drain().is_empty(), "no event was queued"); } + +/// A stable key names one tap on the newest committed plan, whatever position the plan gives its +/// effect, and a key the plan has not is refused with its own reason before anything is written +/// (#1677): the function the browser's record calls and the future C ABI observe edit (#1584) +/// must call. +/// +/// Red if the key resolves to a position of an older plan (after the removal the producers move up +/// and `comp2` is another position), if a strip without the effect is reported as an unknown strip +/// (or an unknown strip as an unknown effect), or if the rack, the effect ID or the tap ID is not +/// part of the key. +#[test] +fn a_stable_key_resolves_on_the_newest_plan_and_an_unknown_key_is_refused() { + let resolve = |rig: &Rig, strip: &str, rack: LiveEffectRack, effect: &str, tap: u32| { + rig.session.resolve_observation_key(&ObservationKeyRef { + strip_id: strip.as_bytes(), + rack, + effect_id: effect.as_bytes(), + tap_id: tap, + }) + }; + let mut rig = Rig::new(); + let before = rig.position("comp2"); + assert_eq!( + resolve(&rig, "comp2", LiveEffectRack::Inserts, "comp", 1), + Ok(ResolvedObservationTap { + producer: before, + tap_index: 0 + }) + ); + for (what, found, expected) in [ + ( + "an unknown strip", + resolve(&rig, "nowhere", LiveEffectRack::Inserts, "comp", 1), + ObservationKeyError::UnknownStrip, + ), + ( + "an unknown insert", + resolve(&rig, "comp2", LiveEffectRack::Inserts, "gone", 1), + ObservationKeyError::UnknownEffect, + ), + ( + "the insert's ID in the console rack", + resolve(&rig, "comp2", LiveEffectRack::Console, "comp", 1), + ObservationKeyError::UnknownEffect, + ), + ( + "an undeclared tap", + resolve(&rig, "comp2", LiveEffectRack::Inserts, "comp", 99), + ObservationKeyError::UnknownTap, + ), + ( + "the reserved tap zero", + resolve(&rig, "comp2", LiveEffectRack::Inserts, "comp", 0), + ObservationKeyError::UnknownTap, + ), + ] { + assert_eq!(found, Err(expected), "{what}"); + } + + // A rebuild is committed and not rendered: the newest plan has no insert on comp1, and comp2's + // producer has moved up one position. The key follows the effect, not the position. + rig.apply(&[remove_insert("comp1")]).expect("a rebuild"); + let after = rig.position("comp2"); + assert_ne!(after, before, "the removal moved comp2's producer"); + assert_eq!( + resolve(&rig, "comp2", LiveEffectRack::Inserts, "comp", 1), + Ok(ResolvedObservationTap { + producer: after, + tap_index: 0 + }) + ); + assert_eq!( + &*rig.session.effect_producers()[after].track_id, + "comp2", + "the position names the producer of the key's strip" + ); + assert_eq!( + resolve(&rig, "comp1", LiveEffectRack::Inserts, "comp", 1), + Err(ObservationKeyError::UnknownEffect), + "comp1 is still a strip, with no such insert" + ); + // The block that adopts it, then a rebuild that removes the whole strip. + rig.render(); + rig.apply(&[ + SessionEdit::RemoveRoute { + route_id: StableId::parse("comp1-main").expect("route"), + }, + SessionEdit::RemoveTrack { + track_id: StableId::parse("comp1").expect("strip"), + }, + ]) + .expect("a strip removed"); + assert_eq!( + resolve(&rig, "comp1", LiveEffectRack::Inserts, "comp", 1), + Err(ObservationKeyError::UnknownStrip) + ); +} + +fn put_compressor(strip: &str, id: &str) -> SessionEdit { + SessionEdit::PutTrackEffect { + track_id: StableId::parse(strip).expect("strip"), + rack_name: RackName::Inserts, + final_position: 1, + effect: Effect { + id: StableId::parse(id).expect("insert"), + identity: EffectIdentity::Native { + effect_id: StableId::parse("miso.compressor").expect("compressor"), + }, + quality: EffectQuality::Normal, + bypass: false, + link_mode: LinkMode::DualMono, + params: Vec::new(), + sidechain: SidechainDeclaration::None, + }, + } +} + +impl Rig { + /// The position of the producer of `strip`'s insert `id`. + fn insert_position(&self, strip: &str, id: &str) -> usize { + self.session + .effect_producers() + .iter() + .position(|producer| &*producer.track_id == strip && &*producer.effect_id == id) + .expect("the insert has a producer") + } + + /// Add one compressor insert per ID, one request each, and let the plan adopt each. + fn add_inserts(&mut self, ids: &[String]) { + self.add_inserts_to("comp1", ids); + } + + /// [`Self::add_inserts`] on `strip`. + fn add_inserts_to(&mut self, strip: &str, ids: &[String]) { + for id in ids { + self.apply(&[put_compressor(strip, id)]) + .expect("the insert is added"); + self.render(); + self.render(); + self.drain(); + } + } + + /// Every observation slot of the newest plan. + fn slots(&self) -> usize { + self.session + .effect_producers() + .iter() + .map(host_core::EffectControlProducer::observe_capacity) + .sum() + } + + fn last_retained(&self) -> u64 { + self.session + .test_transaction_snapshot() + .resource_rows + .last() + .expect("a plan row") + .1 + .control_retained_bytes + } +} + +/// The cap is the number of keys one control frame carries with every ID at its longest: the event +/// of exactly that many such keys fits the frame, and one more does not. +/// +/// Red if the cap is computed from short IDs (a removal of that many long-named taps could not be +/// sent), from the wrong frame limit, or rounds up. +#[test] +fn the_cap_is_what_one_frame_carries_with_the_longest_ids() { + let id = "a".repeat(session::STABLE_ID_MAXIMUM_BYTES); + let encoded = |count: usize, frame: usize| { + let event = protocol::ObservationEnded { + event_sequence: u64::MAX, + keys: (0..count) + .map(|tap| protocol::ObservationTapKey { + strip_id: id.clone(), + section: ObservationSection::Inserts, + effect_id: id.clone(), + tap_id: u32::try_from(tap).expect("tap"), + }) + .collect(), + }; + let codec = ProtocolCodec::new(protocol::ProtocolLimits { + max_frame_bytes: frame, + ..protocol::ProtocolLimits::default() + }); + codec.encode_event_frame_into( + &protocol::TypedEventFrame { + revision: SessionRevision(u64::MAX), + payload: protocol::EventPayload::ObservationEnded(&event), + }, + &mut [], + ) + }; + let mut previous = 0; + for frame in [1_024_u64, 4_096, 16_384] { + let mut small = limits(); + small.maximum_control_frame_bytes = frame; + let cap = Rig::with_limits(small).session.observation_cap(); + assert!(cap > previous, "{frame}: the cap grows with the frame"); + previous = cap; + let frame = usize::try_from(frame).expect("frame"); + assert!( + matches!(encoded(cap, frame), Err(protocol::EncodeError::OutputTooSmall { required }) if required <= frame), + "{frame}: {cap} longest keys fit" + ); + assert!( + !matches!(encoded(cap + 1, frame), Err(protocol::EncodeError::OutputTooSmall { required }) if required <= frame), + "{frame}: one more does not" + ); + } +} + +/// `comp1` renamed to a strip ID of the longest length, so its ended keys are as long as the keys +/// the cap is computed from. +fn long_strip() -> String { + let id = format!("comp1{}", "x".repeat(session::STABLE_ID_MAXIMUM_BYTES - 5)); + assert_eq!(id.len(), session::STABLE_ID_MAXIMUM_BYTES); + id +} + +/// The fixture with `comp1` named [`long_strip`]; its route keeps the ID `comp1-main`. +fn long_session() -> String { + let renamed = SESSION.replace("\"comp1\"", &format!("\"{}\"", long_strip())); + assert_eq!( + renamed.matches(&long_strip()).count(), + 2, + "the track and its route source" + ); + renamed +} + +/// An insert ID of the longest length. +fn long_insert(index: usize) -> String { + let id = format!( + "ins{index:02}{}", + "x".repeat(session::STABLE_ID_MAXIMUM_BYTES - 5) + ); + assert_eq!(id.len(), session::STABLE_ID_MAXIMUM_BYTES); + id +} + +/// The edits that remove [`long_strip`] with its route: every subscribed insert goes with it. +fn remove_long_strip() -> [SessionEdit; 2] { + [ + SessionEdit::RemoveRoute { + route_id: StableId::parse("comp1-main").expect("route"), + }, + SessionEdit::RemoveTrack { + track_id: StableId::parse(&long_strip()).expect("strip"), + }, + ] +} + +/// A small-frame rig with [`long_strip`] carrying `count` subscribable inserts of the longest IDs, +/// and their producer positions. +fn long_rig(count: usize) -> (Rig, Vec) { + let mut small = limits(); + small.maximum_control_frame_bytes = 1_024; + let mut rig = Rig::with_session(&long_session(), small); + let strip = long_strip(); + let ids: Vec = (0..count).map(long_insert).collect(); + rig.add_inserts_to(&strip, &ids); + let positions = ids + .iter() + .map(|id| rig.insert_position(&strip, id)) + .collect(); + (rig, positions) +} + +/// A subscription past the cap is refused before it is acknowledged and changes nothing; removing +/// every subscribed target afterwards commits, with one event holding every key. Every ID is of the +/// longest length, so the event that ends `cap` taps fills the frame: the cap is exactly what one +/// frame carries, and one tap more (pushed past admission) reaches the over-frame defence. +/// +/// Red if the cap is not enforced at admission, or is one too high (the `cap + 1`-th arm is then +/// admitted, and the removal of `cap + 1` longest keys is refused for its event); if a refused batch +/// moves the armed count or queues a record; if an arm of an armed tap counts as a new one; if the +/// refusal names the wrong change of the batch; or if a disarm in the batch is not counted against +/// its arms. +#[test] +fn a_subscription_past_the_cap_is_refused_and_the_removal_of_all_still_commits() { + let cap = { + let mut small = limits(); + small.maximum_control_frame_bytes = 1_024; + Rig::with_limits(small).session.observation_cap() + }; + assert!(cap >= 2, "the small frame still holds some keys: {cap}"); + let (mut rig, positions) = long_rig(cap + 1); + // Arm the first `cap` taps. + for position in &positions[..cap] { + rig.observe(*position, true).expect("under the cap"); + } + assert_eq!(rig.session.observation_armed(), cap); + rig.render(); + // Re-arming an armed tap adds nothing. + rig.observe(positions[0], true) + .expect("a window change adds no tap"); + rig.render(); + // One more tap is past the cap, alone or after a change that adds nothing; the refusal names + // the arm that crossed, and nothing moves or is queued. + let room = |rig: &Rig| -> Vec { + rig.session + .effect_producers() + .iter() + .map(host_core::EffectControlProducer::observe_available_capacity) + .collect() + }; + let (bindings, free) = (rig.session.observation_bindings().clone(), room(&rig)); + assert_eq!( + rig.observe(positions[cap], true), + Err(ObservationRefusal::Limit(ObservationLimit { + index: 0, + cap + })) + ); + assert_eq!( + rig.batch(&[change(positions[1], true), change(positions[cap], true)]), + Err(ObservationRefusal::Limit(ObservationLimit { + index: 1, + cap + })), + "the second change crosses: the first re-arms an armed tap" + ); + assert_eq!( + rig.session.observation_armed(), + cap, + "a refusal moves nothing" + ); + assert_eq!(room(&rig), free, "a refusal queues nothing"); + rig.render(); + assert_eq!(rig.session.observation_bindings(), &bindings); + // A disarm later in the batch makes room for an arm earlier in it. + rig.batch(&[change(positions[cap], true), change(positions[0], false)]) + .expect("a disarm in the batch makes room"); + assert_eq!(rig.session.observation_armed(), cap); + rig.render(); + // Remove every subscribed target: `cap` keys of the longest IDs, in one event in one frame. + let before = rig.revision(); + rig.apply(&remove_long_strip()) + .expect("the removal of every subscribed tap commits"); + assert_eq!(rig.revision(), before + 1); + assert_eq!(rig.session.observation_armed(), 0); + let events = rig.drain(); + let ended = ended(&events); + assert_eq!(ended.len(), 1, "one event"); + assert_eq!(ended[0].1.keys.len(), cap, "holding every key"); + assert!( + ended[0] + .1 + .keys + .iter() + .all(|key| key.strip_id.len() == session::STABLE_ID_MAXIMUM_BYTES + && key.effect_id.len() == session::STABLE_ID_MAXIMUM_BYTES), + "every key is of the longest length" + ); + + // One tap more than the cap, pushed past admission: its end does not fit one frame, so the + // same removal reaches the over-frame defence and is refused whole. + let (mut over, positions) = long_rig(cap + 1); + for position in &positions { + over.session + .test_push_observe_unadmitted(*position, 0, true, WINDOW_BLOCKS) + .expect("a producer") + .expect("room in the queue"); + } + assert_eq!(over.session.observation_armed(), cap + 1); + over.render(); + let revision = over.revision(); + let refused = over.apply(&remove_long_strip()); + assert!( + matches!( + refused, + Err(CommandError::CompileRejected(CompileFailure::Resource( + ResourceFault::Limit + ))) + ), + "cap + 1 longest keys do not fit one frame: {refused:?}" + ); + assert_eq!(over.revision(), revision); +} + +/// A rebuild keeps the armed count of the taps it carries, so the cap holds across plans. +/// +/// Red if a successor starts with an empty count (the cap would then admit twice the taps) or +/// counts a tap whose target the transaction removed. +#[test] +fn the_armed_count_follows_the_bindings_across_a_rebuild() { + let mut rig = Rig::new(); + let ids: Vec = (0..3).map(|index| format!("ins{index:02}")).collect(); + rig.add_inserts(&ids); + for id in &ids { + let position = rig.insert_position("comp1", id); + rig.observe(position, true).expect("under the cap"); + } + assert_eq!(rig.session.observation_armed(), 3); + // Remove one subscribed insert: the other two stay armed in the successor. + rig.apply(&[SessionEdit::RemoveTrackEffect { + track_id: StableId::parse("comp1").expect("strip"), + rack_name: RackName::Inserts, + effect_id: StableId::parse("ins00").expect("insert"), + }]) + .expect("the insert is removed"); + assert_eq!(rig.session.observation_armed(), 2); + assert_eq!(rig.session.observation_bindings().len(), 2); + // The successor knows them armed: disarming one lowers the count, a second disarm does not. + let position = rig.insert_position("comp1", "ins01"); + for expected in [1, 1] { + rig.observe(position, false).expect("a disarm is admitted"); + rig.render(); + assert_eq!(rig.session.observation_armed(), expected); + } +} + +/// The memory reserved for the ended-event cells follows the observation slots the session has had, +/// not the frame: the same plan reserves the same at a 4 KiB and a 16 KiB frame, and a plan that +/// shrank keeps the reserve of the larger plan whose event may still wait in a cell. +/// +/// Red if the reserve scales with the frame size again, or if a shrunk plan charges less than the +/// event of its larger predecessor can hold. +#[test] +fn the_reserve_follows_the_slots_and_a_shrunk_plan_keeps_the_larger_reserve() { + let reserve = |frame: u64, slots: usize| { + control_plane::observation_reserve_bytes(frame, slots).expect("reserve") + }; + assert_eq!( + reserve(4_096, 4), + reserve(16_384, 4), + "a few slots do not scale with the frame" + ); + assert!( + reserve(16_384, 100_000).0 > reserve(4_096, 100_000).0, + "many slots are capped by the frame" + ); + + let mut grown = Rig::new(); + let baseline = Rig::new(); + let (small_slots, small_retained) = (baseline.slots(), baseline.last_retained()); + let ids: Vec = (0..6).map(|index| format!("ins{index:02}")).collect(); + grown.add_inserts(&ids); + let big_slots = grown.slots(); + assert!(big_slots > small_slots); + let removals: Vec = ids + .iter() + .map(|id| SessionEdit::RemoveTrackEffect { + track_id: StableId::parse("comp1").expect("strip"), + rack_name: RackName::Inserts, + effect_id: StableId::parse(id).expect("insert"), + }) + .collect(); + grown.apply(&removals).expect("the inserts are removed"); + assert_eq!( + grown.slots(), + small_slots, + "the plan shrank to the boot plan" + ); + let frame = limits().maximum_control_frame_bytes; + let extra = reserve(frame, big_slots).0 - reserve(frame, small_slots).0; + assert!(extra > 0); + // Other rows carry a little history (the shrunk plan's graph differs from the boot plan's by + // a few bytes), so the charge is at least the larger reserve, not exactly it. + assert!( + grown.last_retained() - small_retained >= extra, + "the shrunk plan charges the larger plan's reserve" + ); +} + +/// A batch that names one tap twice is refused at the second naming, before the cap and the room +/// are judged, and moves nothing (#1676, batch 13b fold). The cap counts each tap's state once, so +/// `[disarm A, arm A, arm B]` with the cap full and `A` armed counts the disarm as a removal, does +/// not count the re-arm, and admits `B`: `cap + 1` taps armed. The shipped effects declare one tap +/// each, so the queue's one slot happens to refuse that batch as `Full`; an effect with two taps, +/// or a C ABI edit that names a tap twice, would not. +/// +/// Red if the core does not refuse a second naming of a tap itself (the batch is then refused as +/// `Full` at change 0, or as `Limit` at change 0 for `[arm B, arm B]`), or names the wrong change. +#[test] +fn a_batch_that_names_one_tap_twice_is_refused_at_the_second_naming() { + let cap = { + let mut small = limits(); + small.maximum_control_frame_bytes = 1_024; + Rig::with_limits(small).session.observation_cap() + }; + let (mut rig, positions) = long_rig(cap + 1); + for position in &positions[..cap] { + rig.observe(*position, true).expect("under the cap"); + } + rig.render(); + let room = |rig: &Rig| -> Vec { + rig.session + .effect_producers() + .iter() + .map(host_core::EffectControlProducer::observe_available_capacity) + .collect() + }; + let free = room(&rig); + assert_eq!( + rig.batch(&[ + change(positions[0], false), + change(positions[0], true), + change(positions[cap], true), + ]), + Err(ObservationRefusal::Duplicate { index: 1 }), + "the re-arm of a tap the batch disarms is its second naming" + ); + assert_eq!( + rig.batch(&[change(positions[cap], true), change(positions[cap], true)]), + Err(ObservationRefusal::Duplicate { index: 1 }), + "two arms of one tap are refused as such, not as the cap" + ); + assert_eq!( + rig.session.observation_armed(), + cap, + "a refusal moves nothing" + ); + assert_eq!(room(&rig), free, "a refusal queues nothing"); + // The same taps named once each, in later batches, are admitted: the marks of a refused batch + // do not leak into the next one. + rig.batch(&[change(positions[cap], true), change(positions[0], false)]) + .expect("each tap named once"); + assert_eq!(rig.session.observation_armed(), cap); +} diff --git a/crates/controls/src/effect_rows.rs b/crates/controls/src/effect_rows.rs index 5c04d177b..d6eea1f45 100644 --- a/crates/controls/src/effect_rows.rs +++ b/crates/controls/src/effect_rows.rs @@ -146,18 +146,12 @@ fn class_of(reason: Option) -> ControlClass { /// The class of one effect parameter's row. A row is `Live` only when the plan keeps a cell that /// render reads each block, and that is a `Block`-rate parameter (`effect_contract::live`): a -/// `None`-rate parameter is prepared (its descriptor states why), and a `Sample`-rate one has no -/// render-path delivery yet, so a change to it rebuilds (`EFFECT_CONTRACT_V1.md`, "V1 runtime -/// automation"). A value is automatable only if it is live (decision 14 rule 4). +/// `None`-rate parameter is prepared (its descriptor states why). The sample rate is retired +/// (#1636). A value is automatable only if it is live (decision 14 rule 4). fn parameter_class(parameter: &ParameterDescriptor) -> ControlClass { match parameter.automation_rate { AutomationRate::Block => ControlClass::Live, AutomationRate::None => class_of(parameter.prepared_reason), - AutomationRate::Sample => ControlClass::RebuildOnly { - reason: RebuildReason::Correctness, - reopen: "sample-accurate render-path delivery, a later protocol capability \ - (EFFECT_CONTRACT_V1.md, V1 runtime automation)", - }, } } @@ -438,6 +432,34 @@ fn identity_of<'a>( } } +/// Hold an effect-section edit to the effect it was written for: `Ok` when the insert or console +/// slot `address` names runs the native effect `expected` in `model`, or when `address` names no +/// instance at all (the validator that follows answers [`ControlError::AddressUnknown`] with its own +/// code). +/// +/// # Errors +/// +/// [`ControlError::EffectChanged`] when the instance runs another native effect or a `cid` one, and +/// when `address` is not in an effect section: an expected effect on a row that has none cannot be +/// honoured, so it is refused rather than ignored. +pub fn check_expected_effect( + model: &SessionModel, + address: &ControlAddress, + expected: &StableId, +) -> Result<(), ControlError> { + let Owner::Strip(strip) = &address.owner else { + return Err(ControlError::EffectChanged); + }; + if matches!(address.section, Section::Own | Section::Builtins) { + return Err(ControlError::EffectChanged); + } + match identity_of(model, strip, &address.section) { + None => Ok(()), + Some(EffectIdentity::Native { effect_id }) if effect_id == expected => Ok(()), + Some(_) => Err(ControlError::EffectChanged), + } +} + /// The declared observation tap `tap_id` of the effect at `section` of strip `id`, if the strip, /// the instance and the tap exist and the tap is [`ObservationCost::Resident`] (a computed tap has /// no subscription path). `None` for a `cid` effect, an unknown effect and a section that is not @@ -598,61 +620,3 @@ fn write_parameter( .then(left.channel.cmp(&right.channel)) }); } - -#[cfg(test)] -mod tests { - use effect_contract::{ParameterId, SmoothingRule}; - - use super::*; - - const fn double(automation_rate: AutomationRate) -> ParameterDescriptor { - ParameterDescriptor { - id: ParameterId(1), - display_name: "Gain", - name: "gain_db", - display_unit: "dB", - unit: ParameterUnit::Db, - domain: ParameterDomain::Continuous, - minimum: Some(-24.0), - maximum: Some(24.0), - default_value: 0.0, - mapping: ParameterMapping::Linear, - automation_rate, - channel_policy: ParameterChannelPolicy::Shared, - smoothing: SmoothingRule::Linear, - smoothing_samples: 8, - readable: true, - automatable: true, - enum_choices: &[], - lattice: ParameterLattice::arithmetic(0.1, 1), - prepared_reason: None, - } - } - - static BLOCK: ParameterDescriptor = double(AutomationRate::Block); - static SAMPLE: ParameterDescriptor = double(AutomationRate::Sample); - - /// Automation is allowed only on a live row, and a row is live only when render reads a cell for - /// it each block (`Block` rate); no launch effect declares a `Sample`-rate parameter, so the - /// case is held by a double. It replaces the deleted effect-compiler test - /// `an_automatable_sample_rate_target_is_refused`. - /// - /// Red if a `Sample`-rate parameter's row is `Live` again: it would advertise an automation - /// target that no render path delivers. - #[test] - fn a_sample_rate_parameter_is_rebuild_only_and_not_automatable() { - let block = parameter_descriptor(&BLOCK); - assert_eq!(block.class, ControlClass::Live); - assert!(block.automatable()); - - let sample = parameter_descriptor(&SAMPLE); - assert!(matches!( - sample.class, - ControlClass::RebuildOnly { - reason: RebuildReason::Correctness, - .. - } - )); - assert!(!sample.automatable()); - } -} diff --git a/crates/controls/src/error.rs b/crates/controls/src/error.rs index da2330bf2..ebf91775f 100644 --- a/crates/controls/src/error.rs +++ b/crates/controls/src/error.rs @@ -35,6 +35,12 @@ pub enum ControlError { /// the session's `control_smoothing`). The row can carry one length, so the later edit is /// refused rather than silently deciding for both. RampConflict, + /// `control.effect.changed`: an edit of an effect section's row named the native effect it was + /// written for (`SET_CONTROL`'s expected effect), and the insert or console slot it addresses + /// now runs another effect (a rebuild replaced it), or a `cid` one. The numeric row of an + /// effect's control depends on the effect, so the edit is refused before anything is written, + /// never applied to the row with the same number of another effect. + EffectChanged, } impl ControlError { @@ -51,6 +57,7 @@ impl ControlError { Self::RampFixed => "control.ramp.fixed", Self::Monitor => "control.monitor", Self::RampConflict => "control.ramp.conflict", + Self::EffectChanged => "control.effect.changed", } } } diff --git a/crates/controls/src/lib.rs b/crates/controls/src/lib.rs index 8babcd72e..42a5b55c2 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -54,7 +54,7 @@ pub use diff::{ pub use effect_contract::control::{ ControlClass, ControlKind, Family, LiveRampRow, RebuildReason, Taper, }; -pub use effect_rows::{BYPASS_CONTROL, LINK_MODE_CONTROL}; +pub use effect_rows::{BYPASS_CONTROL, LINK_MODE_CONTROL, check_expected_effect}; pub use effective::{ Effective, EffectiveInputs, EffectiveRoute, EffectiveRouteInput, EffectiveShapeError, EffectiveSource, EffectiveStrip, LocalEffective, LocalInputs, ModelInputs, MonitorChange, diff --git a/crates/controls/src/observation.rs b/crates/controls/src/observation.rs index 5a3198c90..ae18b55ba 100644 --- a/crates/controls/src/observation.rs +++ b/crates/controls/src/observation.rs @@ -146,6 +146,20 @@ impl ObservationBindings { } } + /// Whether the tap named by its parts is armed: the strip, the effect section (a console slot + /// when `console`, else an insert), the section's ID and the tap ID. It compares text and + /// allocates nothing, so a caller that holds only borrowed IDs can ask. + #[must_use] + pub fn is_armed_by_parts(&self, strip: &str, console: bool, effect: &str, tap_id: u32) -> bool { + self.bindings.iter().any(|binding| { + let key = &binding.key; + key.tap_id == tap_id + && key.strip.as_str() == strip + && key.section.is_console() == console + && key.section.id().as_str() == effect + }) + } + fn search(&self, key: &TapKey) -> Result { self.bindings .binary_search_by(|binding| binding.key.cmp(key)) diff --git a/crates/delay/src/lib.rs b/crates/delay/src/lib.rs index 258695f0f..3135161ff 100644 --- a/crates/delay/src/lib.rs +++ b/crates/delay/src/lib.rs @@ -2694,13 +2694,13 @@ mod tests { point(9, ParameterChannel::Left, 0, 0.5), // Outside the domain. point(4, ParameterChannel::Both, 0, 2.0), - // Not a point. + // A point whose end sample differs from its start sample. PreparedAutomationSpan { - kind: AutomationSpanKind::Linear, + kind: AutomationSpanKind::Point, channel: ParameterChannel::Both, parameter_index: 4, start_sample: 0, - end_sample: 0, + end_sample: 1, start_value: 0.5, end_value: 0.5, }, diff --git a/crates/effect-contract/src/lib.rs b/crates/effect-contract/src/lib.rs index 1d4d7467f..ad98a3524 100644 --- a/crates/effect-contract/src/lib.rs +++ b/crates/effect-contract/src/lib.rs @@ -412,13 +412,18 @@ macro_rules! scalar_enum { ($name:ident {$($v:ident=$n:expr),+$(,)?})=>{#[repr(u scalar_enum!(ParameterUnit {Db=1,Hz=2,Milliseconds=3,Samples=4,Linear=5,Ratio=6}); scalar_enum!(ParameterDomain {Continuous=1,Boolean=2,Enumeration=3}); scalar_enum!(ParameterMapping {Linear=1,Logarithmic=2,Exponential=3,Stepped=4}); -scalar_enum!(AutomationRate {Sample=1,Block=2,None=3}); +// Value 1 (`Sample`) is retired (#1636): no effect declares it and the engine delivers no +// sample-rate automation. `from_raw(1)` is `None`; the value is never reallocated. +scalar_enum!(AutomationRate {Block=2,None=3}); scalar_enum!(ParameterChannel {Left=1,Right=2,Both=3}); scalar_enum!(EffectQuality {Draft=1,Normal=2,High=3}); pub type Quality = EffectQuality; scalar_enum!(LinkMode {DualMono=1,Maximum=2,Average=3}); scalar_enum!(ResetKind {FullToDefaults=1,DiscontinuityKeepParameters=2}); -scalar_enum!(AutomationSpanKind {Point=1,Step=2,Linear=3,Exponential=4}); +// Values 2 (`Step`), 3 (`Linear`) and 4 (`Exponential`) are retired (#1636): no producer stages +// them and no effect accepts them. `from_raw(2..=4)` is `None`; the values are never reallocated +// and never renumbered. +scalar_enum!(AutomationSpanKind {Point=1}); pub type AutomationKind = AutomationSpanKind; scalar_enum!(ParameterChannelPolicy {Shared=1,PerLane=2}); scalar_enum!(StepSize {Xs=1,Sm=2,Md=3,Lg=4,Xl=5}); @@ -476,7 +481,7 @@ pub fn parameter_automation_smoothing_valid( ) -> bool { let automation_valid = match automation_rate { AutomationRate::None => !automatable && smoothing == SmoothingRule::None, - AutomationRate::Sample | AutomationRate::Block => automatable, + AutomationRate::Block => automatable, }; let smoothing_valid = match smoothing { SmoothingRule::None => smoothing_samples == 0, @@ -1825,13 +1830,6 @@ pub fn valid_runtime_span( s.start_sample == s.end_sample && canonical_bits(s.start_value) == canonical_bits(s.end_value) } - AutomationSpanKind::Step | AutomationSpanKind::Linear => s.end_sample > s.start_sample, - AutomationSpanKind::Exponential => { - s.end_sample > s.start_sample - && s.start_value != 0.0 - && s.end_value != 0.0 - && s.start_value.is_sign_positive() == s.end_value.is_sign_positive() - } } } @@ -1877,7 +1875,7 @@ pub fn validate_automation_block( let parameter = &metadata.descriptor.parameters[span.parameter_index as usize]; if parameter.automation_rate == AutomationRate::None || (parameter.automation_rate == AutomationRate::Block - && (span.kind != AutomationSpanKind::Point || span.start_sample != first_sample)) + && span.start_sample != first_sample) { return Err(ProcessBlockError::Automation); } @@ -2016,32 +2014,6 @@ impl ParameterSmoother { } } -/// Evaluate a prepared linear or exponential segment at one absolute sample. -pub fn automation_segment_value(span: PreparedAutomationSpan, sample: u64) -> Option { - if sample < span.start_sample - || sample > span.end_sample - || span.end_sample <= span.start_sample - { - return None; - } - if sample == span.end_sample { - return Some(span.end_value); - } - let x = (sample - span.start_sample) as f32 / (span.end_sample - span.start_sample) as f32; - match span.kind { - AutomationSpanKind::Linear => { - Some(span.start_value + x * (span.end_value - span.start_value)) - } - AutomationSpanKind::Exponential - if span.start_value != 0.0 - && span.end_value != 0.0 - && span.start_value.is_sign_positive() == span.end_value.is_sign_positive() => - { - Some(span.start_value * math::powf(span.end_value / span.start_value, x)) - } - _ => None, - } -} pub struct StatePayloadOutput<'a> { pub common: &'a mut [u8], pub left: &'a mut [u8], @@ -3239,15 +3211,30 @@ mod bank_width_tests { #[cfg(test)] mod automation_smoothing_validity_tests { - use super::{AutomationRate, SmoothingRule, parameter_automation_smoothing_valid}; + use super::{ + AutomationRate, AutomationSpanKind, SmoothingRule, parameter_automation_smoothing_valid, + }; + + /// Red if a retired automation value parses again (#1636): a descriptor could declare a rate + /// the engine never delivers, or a producer could stage a span no effect accepts. + #[test] + fn retired_automation_values_are_refused() { + assert_eq!(AutomationRate::from_raw(1), None); + assert_eq!(AutomationRate::from_raw(2), Some(AutomationRate::Block)); + assert_eq!(AutomationRate::from_raw(3), Some(AutomationRate::None)); + assert_eq!(AutomationRate::from_raw(4), None); + assert_eq!( + AutomationSpanKind::from_raw(1), + Some(AutomationSpanKind::Point) + ); + for raw in 2..=4 { + assert_eq!(AutomationSpanKind::from_raw(raw), None, "{raw}"); + } + } #[test] fn exhaustive_automation_smoothing_truth_table() { - for automation_rate in [ - AutomationRate::Sample, - AutomationRate::Block, - AutomationRate::None, - ] { + for automation_rate in [AutomationRate::Block, AutomationRate::None] { for automatable in [false, true] { for smoothing in [ SmoothingRule::None, @@ -3258,9 +3245,6 @@ mod automation_smoothing_validity_tests { let expected = matches!( (automation_rate, automatable, smoothing, smoothing_samples), (AutomationRate::None, false, SmoothingRule::None, 0) - | (AutomationRate::Sample, true, SmoothingRule::None, 0) - | (AutomationRate::Sample, true, SmoothingRule::Linear, 1..) - | (AutomationRate::Sample, true, SmoothingRule::OnePole99, 1..) | (AutomationRate::Block, true, SmoothingRule::None, 0) | (AutomationRate::Block, true, SmoothingRule::Linear, 1..) | (AutomationRate::Block, true, SmoothingRule::OnePole99, 1..) @@ -3356,7 +3340,7 @@ mod continuous_mapping_validity_tests { maximum: Some(1.0), default_value: 0.0, mapping: ParameterMapping::Linear, - automation_rate: AutomationRate::Sample, + automation_rate: AutomationRate::Block, channel_policy: ParameterChannelPolicy::PerLane, smoothing: SmoothingRule::Linear, smoothing_samples: 1, diff --git a/crates/effect-contract/src/step.rs b/crates/effect-contract/src/step.rs index a8b19cc27..9bbe21971 100644 --- a/crates/effect-contract/src/step.rs +++ b/crates/effect-contract/src/step.rs @@ -925,7 +925,7 @@ mod tests { maximum: Some(20_000.0), default_value: 80.0, mapping: ParameterMapping::Logarithmic, - automation_rate: AutomationRate::Sample, + automation_rate: AutomationRate::Block, channel_policy: ParameterChannelPolicy::PerLane, smoothing: SmoothingRule::Linear, smoothing_samples: 16, diff --git a/crates/effect-contract/tests/live_window.rs b/crates/effect-contract/tests/live_window.rs index 1494a5207..228447e28 100644 --- a/crates/effect-contract/tests/live_window.rs +++ b/crates/effect-contract/tests/live_window.rs @@ -23,9 +23,9 @@ use lane_rig::{Rig, parameter}; const PER_LANE: usize = 40; /// `Shared` `Block` parameters. const SHARED: usize = 5; -/// Parameters that are not live: a `Sample`-rate one, a `None`-rate one, and a `Block`-rate one -/// that is not automatable. -const NOT_LIVE: usize = 3; +/// Parameters that are not live: a `None`-rate one and a `Block`-rate one that is not +/// automatable. +const NOT_LIVE: usize = 2; const TOTAL: usize = PER_LANE + SHARED + NOT_LIVE; /// The least window the synthetic effect needs: two cells per `PerLane` parameter, one per /// `Shared` one. @@ -76,12 +76,6 @@ const PARAMETERS: [ParameterDescriptor; TOTAL] = { table[index] = if index < NOT_LIVE { match index { 0 => parameter_descriptor( - id, - AutomationRate::Sample, - ParameterChannelPolicy::Shared, - true, - ), - 1 => parameter_descriptor( id, AutomationRate::None, ParameterChannelPolicy::Shared, @@ -180,7 +174,7 @@ static DESCRIPTOR: EffectDescriptor = EffectDescriptor { /// The live parameter cell count is one per `Shared` `Block`-rate parameter and two per `PerLane` /// one, whatever the others are: it is what preparation raises an instance's capacity to. /// -/// Test value: a count that includes a `Sample`-rate, `None`-rate or non-automatable parameter, +/// Test value: a count that includes a `None`-rate or non-automatable parameter, /// or one channel of a `PerLane` pair (an effect would then refuse the twin of a staged span). #[test] fn the_live_cell_count_is_one_per_shared_and_two_per_per_lane_block_parameter() { diff --git a/crates/effect-contract/tests/response_analysis.rs b/crates/effect-contract/tests/response_analysis.rs index 5716a9bf6..e0e3ac09f 100644 --- a/crates/effect-contract/tests/response_analysis.rs +++ b/crates/effect-contract/tests/response_analysis.rs @@ -26,7 +26,7 @@ const PARAMETERS: [ParameterDescriptor; 1] = [ParameterDescriptor { maximum: Some(24.0), default_value: 0.0, mapping: ParameterMapping::Linear, - automation_rate: AutomationRate::Sample, + automation_rate: AutomationRate::Block, channel_policy: ParameterChannelPolicy::Shared, smoothing: SmoothingRule::Linear, smoothing_samples: 8, diff --git a/crates/effect-contract/tests/support/mod.rs b/crates/effect-contract/tests/support/mod.rs index 491df4874..9913d87b3 100644 --- a/crates/effect-contract/tests/support/mod.rs +++ b/crates/effect-contract/tests/support/mod.rs @@ -31,7 +31,7 @@ const PARAMETERS: [ParameterDescriptor; 1] = [ParameterDescriptor { maximum: Some(24.0), default_value: 0.0, mapping: ParameterMapping::Linear, - automation_rate: AutomationRate::Sample, + automation_rate: AutomationRate::Block, channel_policy: ParameterChannelPolicy::Shared, smoothing: SmoothingRule::Linear, smoothing_samples: 8, diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 818b6ec9c..c57a6438a 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1231,6 +1231,202 @@ pub fn route_control_resources(producers: &[GraphRouteControlProducer]) -> Route largest_allocation_bytes: largest, } } +/// Words of the plan's jump-length cell (#1639 D1): the session's `fader_ms`, `mute_ms` and +/// `pan_ms` lengths in samples, in that order. +const JUMP_LENGTH_WORDS: usize = 3; + +/// The session's three live ramp lengths, in samples, that the plan's jump-length cell holds +/// (#1639 D1): the values of `controls::LiveRamps` for the `fader_ms`, `mute_ms` and `pan_ms` keys +/// (`SessionModel::control_smoothing_samples`). A stage resolves one row's length from them with +/// `LiveRamps::for_row`, which owns every row rule (the polarity doubling among them); the graph +/// holds the three values and knows no row. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GraphJumpLengths { + /// The `fader_ms` key, in samples. + pub fader_samples: u32, + /// The `mute_ms` key, in samples. + pub mute_samples: u32, + /// The `pan_ms` key, in samples. + pub pan_samples: u32, +} + +impl GraphJumpLengths { + const fn to_words(self) -> [u32; JUMP_LENGTH_WORDS] { + [self.fader_samples, self.mute_samples, self.pan_samples] + } + + // REALTIME_POLICY_BEGIN + const fn from_words(words: [u32; JUMP_LENGTH_WORDS]) -> Self { + let [fader_samples, mute_samples, pan_samples] = words; + Self { + fader_samples, + mute_samples, + pan_samples, + } + } + // REALTIME_POLICY_END +} + +/// What the plan's jump-length cell shares between its writer (control) and its reader (render): +/// one #1312 latest-target cell and its dirty word (#1639 D1). +struct JumpLengthLane { + cell: engine::realtime::LatestCell, + dirty: engine::realtime::DirtyWord, +} + +/// The render half of the plan's jump-length cell (#1639 D2): the one reader. It is not `Clone` +/// and only [`attach_jump_lengths`] builds it, straight into the executor, so a second reader of +/// the cell cannot be written. +struct JumpLengthReader { + lane: std::sync::Arc, + reader: engine::realtime::CellReader, + dirty: engine::realtime::DirtyReader, + superseded: engine::realtime::LiveValuesSuperseded, + /// The lengths this block holds: the prepared ones until a write is applied. + held: GraphJumpLengths, +} + +// REALTIME_POLICY_BEGIN +impl JumpLengthReader { + /// Render, once per block at block entry, after the block's live snapshot: apply the newest + /// write at or below `snapshot`, if it was not applied before (#1639 D2). An idle cell costs + /// one `Relaxed` load. A write a later one replaced before this read joins the session's + /// `live_values_superseded` count (D15-2). + fn begin_block(&mut self, snapshot: LiveSnapshot) { + if self.dirty.pending(&self.lane.dirty) == 0 { + return; + } + if let engine::realtime::CellRead::Applied { words, .. } = self.reader.read_counted( + &self.lane.cell, + snapshot, + &mut self.dirty, + 0, + &self.superseded, + ) { + self.held = GraphJumpLengths::from_words(words); + } + } +} +// REALTIME_POLICY_END + +/// The control half of a plan's jump-length cell (#1639 D1, D3): the cell's one writer. +pub struct GraphJumpLengthProducer { + lane: std::sync::Arc, + writer: engine::realtime::CellWriter, +} + +impl GraphJumpLengthProducer { + /// Write `lengths` as the plan's newest jump lengths, stamped with the revision of the + /// transaction that commits them. It cannot fail and never waits; the caller publishes the + /// stamp's revision on the plan's gate after every write of the transaction, and render holds + /// the lengths from the first block whose snapshot covers it (#1312 D7). + pub fn write(&mut self, stamp: engine::realtime::LiveStamp<'_>, lengths: GraphJumpLengths) { + self.writer.write( + &self.lane.cell, + &self.lane.dirty, + 0, + stamp, + lengths.to_words(), + ); + } +} + +#[cfg(any(test, feature = "test-support"))] +impl GraphJumpLengthProducer { + /// Test-only: how many writes the cell has taken. Valid while no write is in flight, which a + /// quiescent test thread guarantees. + #[doc(hidden)] + #[must_use] + pub fn test_only_write_count(&self) -> u64 { + engine::realtime::CellReader::new() + .peek_unread(&self.lane.cell) + .map_or(0, |(_, sequence)| sequence) + } +} + +/// Why [`attach_jump_lengths`] refused. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GraphJumpLengthError { + /// The plan is not a graph plan. + NotAGraphPlan, + /// The plan has its jump-length cell already. + AlreadyAttached, +} + +/// The bytes one plan's jump-length cell retains (#1639 D1): the shared allocation of the cell +/// and its dirty word (`SharedAllocation`) and the boxed render-side reader. The +/// same for every session. The producer is held inline by its owner. +#[must_use] +pub fn jump_length_retained_bytes() -> u64 { + let shared = core::mem::size_of::>(); + let reader = core::mem::size_of::(); + u64::try_from(shared.saturating_add(reader)).unwrap_or(u64::MAX) +} + +/// The largest single allocation of [`jump_length_retained_bytes`]. +#[must_use] +pub fn jump_length_largest_allocation_bytes() -> u64 { + let shared = core::mem::size_of::>(); + let reader = core::mem::size_of::(); + u64::try_from(shared.max(reader)).unwrap_or(u64::MAX) +} + +/// Give a bound graph plan its jump-length cell (#1639 D1), holding `lengths` from its first +/// block, and return the cell's one writer. Every write a later one replaces before render reads +/// it is added to `superseded`, the session's count. Off the render thread, before publication. +/// +/// The graph reads the cell once per block at block entry, beside the source set's +/// `begin_block`, and holds the three values for that block (D2). No stage reads the cell itself. +/// +/// # Errors +/// +/// [`GraphJumpLengthError::NotAGraphPlan`] for a plan another executor renders, and +/// [`GraphJumpLengthError::AlreadyAttached`] for a second call on one plan. +pub fn attach_jump_lengths( + plan: &mut PreparedRenderPlan, + lengths: GraphJumpLengths, + superseded: &engine::realtime::LiveValuesSuperseded, +) -> Result { + let executor = plan + .executor_any_mut() + .and_then(|any| any.downcast_mut::()) + .ok_or(GraphJumpLengthError::NotAGraphPlan)?; + if executor.jump_lengths.is_some() { + return Err(GraphJumpLengthError::AlreadyAttached); + } + let lane = std::sync::Arc::new(JumpLengthLane { + cell: engine::realtime::LatestCell::new(), + dirty: engine::realtime::DirtyWord::new(), + }); + executor.jump_lengths = Some(Box::new(JumpLengthReader { + lane: std::sync::Arc::clone(&lane), + reader: engine::realtime::CellReader::new(), + dirty: engine::realtime::DirtyReader::new(), + superseded: superseded.clone(), + held: lengths, + })); + Ok(GraphJumpLengthProducer { + lane, + writer: engine::realtime::CellWriter::new(), + }) +} + +#[cfg(any(test, feature = "test-support"))] +thread_local! { + /// #1639 gate 1: the jump lengths the graph plan rendered last on this thread held for its + /// block, or `None` for a block of a plan with no jump-length cell. + static BLOCK_JUMP_LENGTHS: Cell> = const { Cell::new(None) }; +} + +/// The jump lengths the last graph block rendered on this thread held (#1639 gate 1): the values +/// read at that block's entry, or `None` when its plan has no jump-length cell. +#[cfg(any(test, feature = "test-support"))] +#[doc(hidden)] +#[must_use] +pub fn test_only_block_jump_lengths() -> Option { + BLOCK_JUMP_LENGTHS.with(Cell::get) +} + /// A prepared homogeneous native bank and its original graph member identities. pub struct GraphPreparedEffectBank { /// The member on each active lane, in lane order: lanes `0..members.len()`. @@ -3054,6 +3250,9 @@ struct GraphExecutor { /// [`PreparedPlanExecutor::begin_live_block`] and taken by `render`, so a block that renders /// without its own snapshot is refused and never reads the previous block's. live_snapshot: Option, + /// The plan's jump-length cell reader (#1639 D2), attached after bind by + /// [`attach_jump_lengths`]; `None` for a plan with no cell. + jump_lengths: Option>, } /// Next graph executor identity; identities start at 1, so zero never names a plan. @@ -4257,6 +4456,7 @@ struct GraphExecutorWithoutSplitPairTable { carry_routes: Box<[GraphRouteMove]>, bind_identities: runtime::BindIdentities, live_snapshot: Option, + jump_lengths: Option>, } fn scalar_split_runtime_owner_layout() -> (u64, u64) { @@ -4380,6 +4580,7 @@ impl GraphExecutor { carry_routes: Box::default(), bind_identities, live_snapshot: None, + jump_lengths: None, } } } @@ -4613,11 +4814,18 @@ impl PreparedPlanExecutor for GraphExecutor { carry_routes: _, bind_identities: _, live_snapshot, + jump_lengths, } = self; // One block's snapshot: taken here, so the next block cannot reuse it. let Some(live) = live_snapshot.take() else { return Err(RenderError::InvalidEnvelope); }; + // #1639 D2: the plan's jump lengths, read once at block entry and held for the block. + if let Some(jump_lengths) = jump_lengths.as_deref_mut() { + jump_lengths.begin_block(live); + } + #[cfg(any(test, feature = "test-support"))] + BLOCK_JUMP_LENGTHS.with(|held| held.set(jump_lengths.as_deref().map(|reader| reader.held))); *next_block_sample = time .absolute_sample .saturating_add(runtime.arena.frames() as u64); diff --git a/crates/graph/tests/jump_lengths.rs b/crates/graph/tests/jump_lengths.rs new file mode 100644 index 000000000..31bae0f64 --- /dev/null +++ b/crates/graph/tests/jump_lengths.rs @@ -0,0 +1,291 @@ +//! Issue #1639 gate 1: the graph reads the plan's jump-length cell once at block entry and holds +//! the three lengths for that block. +//! +//! The plan is rendered through the exchange (`RealtimePlanOwner`), so a block's snapshot is what +//! render read from the plan's own revision gate, as on every host. +#![cfg(feature = "test-support")] + +use core::num::NonZeroUsize; +use effect_contract::{LatencySamples, TailSamples}; +use engine::{ + QuantumFrames, SampleRateHz, + realtime::{ + self, PlanExchangeConfig, PlanarBufferMut, PreparedRenderPlan, RealtimePlanOwner, + RenderEnvelope, RenderError, plan_exchange_at_revision, + }, +}; +use graph::*; + +const FRAMES: usize = 4; + +const PREPARED: GraphJumpLengths = GraphJumpLengths { + fader_samples: 960, + mute_samples: 480, + pan_samples: 960, +}; + +/// A source of a constant, nonzero block. +struct Source; +impl GraphRuntimeProcessor for Source { + fn process(&mut self, block: GraphBindingBlock<'_>) -> Result<(), RenderError> { + block.left.fill(0.25); + block.right.fill(-0.25); + Ok(()) + } +} + +fn estimate() -> GraphResourceEstimate { + GraphResourceEstimate { + logical_nodes: 0, + materialized_nodes: 0, + edges: 0, + schedule_items: 0, + dependency_levels: 0, + reductions: 0, + routes: 0, + effects: 0, + audio_buffer_samples: 0, + total_delay_samples: 0, + delay_bytes: 0, + graph_metadata_bytes: 0, + declared_effect_bytes: 0, + effect_bank_count: 0, + effect_bank_scratch_bytes: 0, + effect_bank_runtime_buffer_bytes: 0, + effect_bank_metadata_bytes: 0, + builtin_bank_bytes: 0, + builtin_bank_scratch_bytes: 0, + builtin_bank_count: 0, + largest_allocation_bytes: 0, + incremental_plan_bytes: 0, + session_plus_plan_bytes: 0, + } +} + +/// One track's input feeding the output: the smallest bound graph plan. +fn bound() -> PreparedRenderPlan { + let envelope = RenderEnvelope { + sample_rate: SampleRateHz(48_000), + quantum: QuantumFrames(FRAMES as u32), + output_channels: NonZeroUsize::new(2).expect("stereo"), + }; + let input = GraphNodeId::TrackStage { + track_id: StableGraphId::parse("track").expect("track"), + stage: TrackStage::Input, + }; + let output = GraphNodeId::Output { + output_id: StableGraphId::parse("main").expect("output"), + }; + let node = |id: &GraphNodeId| GraphNode { + id: id.clone(), + latency: LatencySamples(0), + tail: TailSamples::Finite(0), + }; + let mut nodes = vec![node(&input), node(&output)]; + nodes.sort_by(|a, b| a.id.cmp(&b.id)); + let edge = GraphEdge { + id: GraphEdgeId::RouteSource { + route_id: StableGraphId::parse("out").expect("route"), + }, + source: GraphPortId { + node: input.clone(), + kind: GraphPortKind::MainOutput, + effect_port: None, + }, + destination: GraphPortId { + node: output.clone(), + kind: GraphPortKind::MainInput, + effect_port: None, + }, + path: "$.jump_lengths".to_owned(), + }; + let graph = PreparedGraphPlan::new(PreparedGraphPlanParts { + plan_id: 1639, + spec: GraphSpec { + nodes, + ports: vec![], + edges: vec![edge], + }, + sequential_schedule: vec![input.clone(), output.clone()], + dependency_levels: vec![ + DependencyLevel { + level: 0, + nodes: vec![input.clone()], + }, + DependencyLevel { + level: 1, + nodes: vec![output.clone()], + }, + ], + route_timings: vec![], + inserted_delays: vec![], + buffer_assignments: vec![], + estimate: estimate(), + envelope, + required_bindings: vec![input.clone(), output.clone()], + routes: vec![], + track_delays: vec![], + effects: vec![], + effect_controls: vec![], + effect_observations: vec![], + banks: vec![], + builtin_banks: vec![], + observers: vec![], + }); + graph + .bind(GraphRuntimeBindings { + envelope, + nodes: vec![ + GraphNodeBinding::new(input, Box::new(Source)), + GraphNodeBinding::identity(output), + ], + observers: vec![], + }) + .unwrap_or_else(|failure| panic!("bind {}", failure.code)) +} + +/// The exchange around `plan`, whose gate handle the caller keeps. The publisher and the retirer +/// stay alive with the owner. +type Exchange = ( + realtime::PlanRevisionGate, + RealtimePlanOwner, + (realtime::PlanPublisher, realtime::PlanRetirer), +); + +fn exchange(mut plan: PreparedRenderPlan) -> Exchange { + let mut gate = plan.take_revision_gate().expect("a fresh plan's gate"); + let (publisher, owner, retirer) = plan_exchange_at_revision( + plan, + &mut gate, + 1, + PlanExchangeConfig { + retirement_capacity: NonZeroUsize::new(1).expect("one"), + }, + ) + .expect("exchange"); + (gate, owner, (publisher, retirer)) +} + +/// Renders block `block` and returns the calling thread's allocator events inside the render +/// call alone. +fn render(owner: &mut RealtimePlanOwner, block: usize) -> bench_support::alloc::Counters { + let mut pcm = vec![1.0_f32; 2 * FRAMES]; + let mark = bench_support::alloc::current_thread_counters(); + owner + .render( + realtime::RenderIo { + output: PlanarBufferMut::try_new(&mut pcm, 2, FRAMES, FRAMES).expect("output"), + }, + realtime::RenderTime { + absolute_sample: (block * FRAMES) as u64, + }, + ) + .expect("render"); + bench_support::alloc::current_thread_delta_since(mark) +} + +fn lengths(fader_samples: u32, mute_samples: u32, pan_samples: u32) -> GraphJumpLengths { + GraphJumpLengths { + fader_samples, + mute_samples, + pan_samples, + } +} + +/// A fresh plan holds its prepared lengths at its first block. A write stamped and published +/// before a block entry is held from that block on, not before; a write whose revision is not yet +/// published is not held; two writes before one block give the last, and the replaced one is +/// counted superseded. Every block allocates and frees nothing, the block that applies a write +/// included (gate 4's render half). The attach allocates exactly the cell's retained row, in two +/// allocations. +/// +/// Test value: red if the graph reads the cell only at preparation, under no snapshot (a write +/// held before its revision is published), or applies an older write than the newest, or if the +/// cell's allocations drift from the retained row the hosts charge. +#[test] +fn the_block_entry_holds_the_newest_published_lengths() { + bench_support::alloc::assert_installed(); + let superseded = realtime::LiveValuesSuperseded::new(); + // Warm any process static the attach touches on a throwaway plan, so the counted attach + // below sees the cell's own allocations alone. + let mut warm = bound(); + drop(attach_jump_lengths(&mut warm, PREPARED, &superseded).expect("a graph plan's cell")); + drop(warm); + let mut plan = bound(); + let mark = bench_support::alloc::current_thread_counters(); + let mut producer = + attach_jump_lengths(&mut plan, PREPARED, &superseded).expect("a graph plan's cell"); + let cell = bench_support::alloc::current_thread_delta_since(mark); + assert_eq!( + cell.requested_bytes - cell.released_bytes, + jump_length_retained_bytes(), + "the cell's live bytes are the retained row" + ); + assert_eq!( + (cell.allocations, cell.deallocations, cell.reallocations), + (2, 0, 0), + "the cell is two allocations: the shared lane and the plan's reader" + ); + assert_eq!( + attach_jump_lengths(&mut plan, PREPARED, &superseded).err(), + Some(GraphJumpLengthError::AlreadyAttached) + ); + let (mut gate, mut owner, _exchange) = exchange(plan); + // Warm the process statics and the thread-local reader before any count. + let _ = render(&mut owner, 0); + assert_eq!(test_only_block_jump_lengths(), Some(PREPARED)); + let used = render(&mut owner, 1); + assert_eq!((used.allocations, used.deallocations), (0, 0)); + assert_eq!( + test_only_block_jump_lengths(), + Some(PREPARED), + "no write: the prepared lengths" + ); + + // A write stamped but not yet published is not held. + let first = lengths(4_800, 2_400, 4_800); + let revision = gate.last_published() + 1; + producer.write(gate.stamp(revision).expect("a legal revision"), first); + let used = render(&mut owner, 2); + assert_eq!((used.allocations, used.deallocations), (0, 0)); + assert_eq!( + test_only_block_jump_lengths(), + Some(PREPARED), + "an unpublished write is not held" + ); + gate.publish(revision); + let used = render(&mut owner, 3); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "the block that applies a write allocates and frees nothing" + ); + assert_eq!( + test_only_block_jump_lengths(), + Some(first), + "held from the first block after the publication" + ); + assert_eq!(superseded.get(), 0); + + // Two writes before one block: the last is held, and the first is counted superseded. + let replaced = lengths(1, 2, 3); + let last = lengths(0, 0, 0); + for value in [replaced, last] { + let revision = gate.last_published() + 1; + producer.write(gate.stamp(revision).expect("a legal revision"), value); + gate.publish(revision); + } + let used = render(&mut owner, 4); + assert_eq!((used.allocations, used.deallocations), (0, 0)); + assert_eq!( + test_only_block_jump_lengths(), + Some(last), + "the newest write" + ); + assert_eq!(superseded.get(), 1, "the replaced write is superseded"); + assert_eq!(producer.test_only_write_count(), 3); + + // Held across blocks with no write. + let _ = render(&mut owner, 5); + assert_eq!(test_only_block_jump_lengths(), Some(last)); +} diff --git a/crates/host-core/src/control_provider.rs b/crates/host-core/src/control_provider.rs index 3bdf8c9cd..71a8853da 100644 --- a/crates/host-core/src/control_provider.rs +++ b/crates/host-core/src/control_provider.rs @@ -334,7 +334,6 @@ pub struct SessionControlProvider { diagnostics: Vec, diagnostic_live: Vec, transport_state: TransportState, - transport_position: SampleTime, telemetry_configuration: TelemetryConfiguration, /// The most reply frame bytes a `RESPONSE_GET` may answer: the smaller of the endpoint's /// frame bound and the replay cache's response bound. @@ -400,7 +399,6 @@ impl SessionControlProvider { diagnostics: Vec::new(), diagnostic_live: Vec::new(), transport_state: TransportState::Stopped, - transport_position: SampleTime(0), telemetry_configuration: retained_telemetry(retained)?, maximum_reply_bytes, }; @@ -1139,16 +1137,12 @@ impl ControlProvider for SessionControlProvider { fn transport_get(&mut self) -> TransportSnapshot { TransportSnapshot { state: self.transport_state, - position: self.transport_position, effective_sample: self.current_sample(), } } fn transport_set(&mut self, request: TransportSetRequest) -> TransportSnapshot { self.transport_state = request.state; - if let Some(position) = request.position { - self.transport_position = position; - } self.transport_get() } @@ -3186,10 +3180,10 @@ mod tests { assert_eq!(provider.current_sample(), SampleTime(256)); let transport = provider.transport_set(TransportSetRequest { state: TransportState::Playing, - position: Some(SampleTime(64)), }); assert_eq!(transport.effective_sample, SampleTime(256)); - assert_eq!(transport.position, SampleTime(64)); + assert_eq!(transport.state, TransportState::Playing); + assert_eq!(provider.transport_get(), transport); let counter_capacity = provider.retained_capacities().1; assert_eq!(counter_capacity, PROVIDER_COUNTER_SLOTS); diff --git a/crates/host-core/src/lib.rs b/crates/host-core/src/lib.rs index 19469e3e1..ed1b8a454 100644 --- a/crates/host-core/src/lib.rs +++ b/crates/host-core/src/lib.rs @@ -137,18 +137,20 @@ pub use engine::realtime::{ }; #[cfg(feature = "control-provider")] pub use live_delta::{ - LiveDelta, LiveRebuild, LiveRouteRecord, LiveStripRecords, classify_live_delta, - classify_live_delta_with_monitor, classify_live_edit, classify_live_staged, + LiveDelta, LiveRebuild, LiveRouteRecord, LiveStripRecords, automation_jump_row, + classify_live_delta, classify_live_delta_with_monitor, classify_live_edit, + classify_live_staged, }; #[cfg(all(feature = "control-provider", feature = "test-support"))] pub use live_delta::{take_fast_classification_count, take_lowered_owner_count}; pub use prepare::{ - HostLiveControlHandles, HostLiveControlRequest, HostLiveLanes, HostMeterRequest, - HostPrepareCaps, HostPrepareReport, HostShapePolicy, LiveRampRow, LiveRamps, - MAXIMUM_VCA_REACH_PAIRS, MAXIMUM_VCAS_BOUNDED, MonitorBase, NO_OBSERVATION, PlanStateInventory, - PreparedHost, SOURCE_STALL_TOLERANCE_MS, SourceInventoryRow, SuccessorBase, check_counts, - compile_host_model, compile_host_session, count_effects, default_source_ring_frames, - effect_producer_position, pair_carried_meters, pair_carried_observations, parse_host_session, + GraphJumpLengthProducer, GraphJumpLengths, HostLiveControlHandles, HostLiveControlRequest, + HostLiveLanes, HostMeterRequest, HostPrepareCaps, HostPrepareReport, HostShapePolicy, + LiveRampRow, LiveRamps, MAXIMUM_VCA_REACH_PAIRS, MAXIMUM_VCAS_BOUNDED, MonitorBase, + NO_OBSERVATION, PlanStateInventory, PreparedHost, SOURCE_STALL_TOLERANCE_MS, + SourceInventoryRow, SuccessorBase, check_counts, compile_host_model, compile_host_session, + count_effects, default_source_ring_frames, effect_producer_position, + jump_length_retained_bytes, pair_carried_meters, pair_carried_observations, parse_host_session, prepare_host_runtime, prepare_host_runtime_between_render_calls, prepare_host_runtime_successor, prepare_host_runtime_with_live_controls, prepare_host_runtime_with_live_controls_and_spectrum, @@ -160,7 +162,8 @@ pub use prepare::{ prepare_host_runtime_with_selected_meters_between_render_calls, prepare_host_runtime_with_selected_meters_successor, prepare_host_runtime_with_spectrum, prepare_host_runtime_with_spectrum_collection, prepare_host_session, - prepare_host_session_with_live_controls, strip_control_table_bytes, strip_position, + prepare_host_session_with_live_controls, session_jump_lengths, strip_control_table_bytes, + strip_position, }; pub use render_session::StartedRenderSession; pub use response::{ diff --git a/crates/host-core/src/live_delta.rs b/crates/host-core/src/live_delta.rs index 5269535f0..574e6a717 100644 --- a/crates/host-core/src/live_delta.rs +++ b/crates/host-core/src/live_delta.rs @@ -63,7 +63,7 @@ use crate::prepare::{ effect_instance_records, effective_fader, fader_records, lane_records, level_differs, matrix_record, route_record_between, route_source_strip, same_params, strip_at, }; -use crate::prepare::{LiveRampRow, LiveRamps, MonitorBase}; +use crate::prepare::{GraphJumpLengths, LiveRampRow, LiveRamps, MonitorBase, session_jump_lengths}; /// The records of one strip whose live values change. #[derive(Clone, Debug, PartialEq)] @@ -178,6 +178,12 @@ pub struct LiveDelta<'a> { /// control catalog then stages the derived rows of these owners and of `changed_owners` and no /// others. `None` when the classification compared the whole models: every derived row is read. pub derived_owners: Option>, + /// The new value of the plan's jump-length cell (#1639 D3): `Some` when the session's + /// `control_smoothing` lengths in samples differ between the two models and the post-commit + /// model holds at least one stored automation whose jumps ramp over one of them + /// ([`automation_jump_row`]); `None` otherwise, equal sample counts included. The host writes + /// it to the newest plan's cell after every fallible check, with the transaction's records. + pub jump_lengths: Option, } impl LiveDelta<'_> { @@ -199,11 +205,48 @@ impl LiveDelta<'_> { changed_owners: _, settings_changed: _, derived_owners: _, + jump_lengths, } = self; - strips.is_empty() && effects.is_empty() && routes.is_empty() + strips.is_empty() && effects.is_empty() && routes.is_empty() && jump_lengths.is_none() } } +/// The live ramp row whose session length the jumps of a stored automation on `target` ramp over +/// (#1639 D3, #1637 D2 as amended): the row of a control whose live motion is a +/// `control_smoothing` ramp (`controls::row_motion`), and an effect's bypass, whose crossfade is +/// [`LiveRampRow::BypassCrossfade`]. A stage resolves the row's length with +/// [`LiveRamps::for_row`]. `None` for every other row: an effect parameter, the input filters and +/// the EQ keep their fixed ramps, and a link-mode row is not automatable. +#[must_use] +pub fn automation_jump_row(target: &ControlAddress) -> Option { + match &target.section { + Section::Console(_) | Section::Inserts(_) => { + (target.control == controls::BYPASS_CONTROL).then_some(LiveRampRow::BypassCrossfade) + } + Section::Own | Section::Builtins => { + match controls::row_motion(target.owner.kind(), target.section.kind(), target.control) { + Some(controls::Motion::Ramp(row)) => Some(row), + Some(controls::Motion::Fixed) | None => None, + } + } + } +} + +/// D3 of #1639: the new jump lengths when `current` and `next` differ in them, in samples, and +/// `next` holds an automation whose jumps read one of them. The rule reads the session only, not +/// which rows render yet: every plan has the cell, so the write always lands, and a row whose +/// rendering slice is still to come reads the new value from its first jump. +fn jump_length_change(current: &SessionModel, next: &SessionModel) -> Option { + let after = session_jump_lengths(next); + if session_jump_lengths(current) == after { + return None; + } + next.automation + .iter() + .any(|automation| automation_jump_row(&automation.target).is_some()) + .then_some(after) +} + pub use crate::prepare::LiveRebuild; /// Classifies the delta from `current` to `next` as live records or a rebuild (#1053 D1, #1554). @@ -247,8 +290,11 @@ pub use crate::prepare::LiveRebuild; /// /// The session ID, the two profile IDs, the stored automation and `control_smoothing` are /// model-only: no prepared plan reads them (#1260, #1365), so a delta that changes only them is -/// live with no records, once step 3 has accepted the automation's targets. `control_smoothing`'s -/// only reader is [`LiveRamps::for_session`], which the caller evaluates on `next`: the records +/// live with no records, once step 3 has accepted the automation's targets. The one exception is +/// the plan's jump-length cell (#1639 D3): when the lengths in samples differ and `next` holds a +/// stored automation whose jumps ramp over one of them ([`automation_jump_row`]), the delta +/// carries [`LiveDelta::jump_lengths`] and is not empty. `control_smoothing`'s +/// record reader is [`LiveRamps::for_session`], which the caller evaluates on `next`: the records /// of the same transaction use the new lengths, and a ramp already in flight keeps its own. A /// preparation that bakes a ramp length into a plan (a transition decided at preparation) reads /// the key at that preparation only, so a later edit still governs only later transitions. The @@ -389,6 +435,7 @@ pub fn classify_live_delta_with_monitor<'a>( let mut delta = LiveDelta { changed_owners: core::mem::take(&mut accumulated.changed_owners), settings_changed: accumulated.settings_changed, + jump_lengths: jump_length_change(current, next), ..LiveDelta::default() }; let diff = diff.as_ref().map(|diff| diff as &dyn LevelDiff); @@ -624,6 +671,8 @@ fn classify_rows<'a>( .or_insert_with(|| Work::new(view.strip(index).id.clone())); } } + // No jump-length write here (#1639 D3): the session's `control_smoothing` rows are never judged + // on this path (`controls::changed_rows` refuses them), so the lengths are the committed ones. let mut delta = LiveDelta { changed_owners: core::mem::take(&mut accumulated.changed_owners), settings_changed: accumulated.settings_changed, diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index 26ead26af..f423b9b92 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -38,6 +38,7 @@ use graph::{ GraphCompileCaps, GraphNodeBinding, GraphNodeId, GraphRuntimeBindings, StableGraphId, TrackStage, }; +pub use graph::{GraphJumpLengthProducer, GraphJumpLengths, jump_length_retained_bytes}; use graph_compiler::{Backend, GraphBuiltinsCompileRequest, GraphCompiler}; use lane::kernels::INDEXED_RAMP_LENGTH_MAXIMUM; use session::{ @@ -66,6 +67,19 @@ use crate::spectrum::{ prepare_capture_collection, spectrum_capture_collection_resources, }; +/// The jump lengths a plan's cell holds for `model` (#1639 D1): its `control_smoothing` keys in +/// samples, the values of [`LiveRamps::for_session`]. Preparation seeds every plan's cell with +/// them, and a live `control_smoothing` change writes them ([`crate::LiveDelta::jump_lengths`]). +#[must_use] +pub fn session_jump_lengths(model: &SessionModel) -> GraphJumpLengths { + let ramps = LiveRamps::for_session(model); + GraphJumpLengths { + fader_samples: ramps.fader_samples, + mute_samples: ramps.mute_samples, + pan_samples: ramps.pan_samples, + } +} + #[derive(Clone, Copy)] enum SpectrumPreparationRequest<'a> { Single(&'a SpectrumCaptureRequest), @@ -520,6 +534,10 @@ pub struct HostLiveControlHandles { /// [`prepare_host_runtime_with_live_lanes`], else a fresh count this preparation made. Read /// it with [`LiveValuesSuperseded::get`]. pub live_values_superseded: LiveValuesSuperseded, + /// The writer of the plan's jump-length cell (#1639 D1), which preparation seeded with + /// [`session_jump_lengths`]. Every prepared plan has the cell; `None` only in a handle set a + /// caller built without a preparation. + pub jump_lengths: Option, } impl HostLiveControlHandles { @@ -2501,18 +2519,18 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( let output_latency = artifact.report().output_latency; let output_tail = artifact.report().output_tail; let mut graph_resources = artifact.graph_resource_estimate().clone(); - // The plan's own allocations that the graph row does not count: its revision gate (#1502 D1) - // and, when this preparation made its own `live_values_superseded` count rather than taking the - // session's, and strips and routes hold a clone of it in their cell readers, that count (#1312 - // D2; a session's count is the host's to charge once). A preparation with no strip controls - // keeps no reader of the count (a route lane exists only beside strips), so the plan retains - // none and charges none. Charged here so every host, the C ABI and the browser, counts them in - // the one row. + // The plan's own allocations that the graph row does not count: its revision gate (#1502 D1), + // its jump-length cell (#1639 D1, attached after bind below) and, when this preparation made + // its own `live_values_superseded` count rather than taking the session's, that count (#1312 + // D2; a session's count is the host's to charge once): the jump-length cell's reader holds a + // clone of it in every plan. Charged here so every host, the C ABI and the browser, counts + // them in the one row. graph_resources.session_plus_plan_bytes = graph_resources .session_plus_plan_bytes .checked_add(engine::realtime::plan_revision_gate_retained_bytes()) + .and_then(|bytes| bytes.checked_add(graph::jump_length_retained_bytes())) .and_then(|bytes| { - bytes.checked_add(if superseded.is_none() && !control_requests.is_empty() { + bytes.checked_add(if superseded.is_none() { LiveValuesSuperseded::retained_bytes() } else { 0 @@ -2578,6 +2596,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( } let largest_engine_allocation_bytes = graph_resources .largest_allocation_bytes + .max(graph::jump_length_largest_allocation_bytes()) .max(source_resources.largest_allocation_bytes) .max(carried_largest_bytes) .max(builtin_resources.maximum_single_allocation_bytes) @@ -2713,6 +2732,12 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( .retained_bytes() .ok_or_else(|| resource("host.resource.arithmetic"))?; let mut plan = bound.plan; + // #1639 D1: the plan's jump-length cell, seeded with the session's lengths, so the plan holds + // them from its first block. A successor is seeded from the committed model, which holds any + // edit of them (D4): nothing carries. + let jump_lengths = + graph::attach_jump_lengths(&mut plan, session_jump_lengths(model), &live_superseded) + .map_err(|_| graph_failure("host.jump_lengths.attach"))?; // Issue #1272 D4: the join's program goes in before the plan leaves preparation. let has_carry_program = carry_program.is_some(); if let Some(program) = carry_program { @@ -3234,6 +3259,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( route_controls, master_track, live_values_superseded: live_superseded, + jump_lengths: Some(jump_lengths), }, spectrum_capture, )) @@ -4315,9 +4341,9 @@ fn parameter_records( } let parameter = &descriptor.parameters[value_after.parameter_index as usize]; // A row is live only when the plan keeps a block-rate cell for it, and the control rows - // say so: only a `Block`-rate parameter is `Live` (`controls::effect_rows`), a `Sample`-rate - // one is rebuild-only for correctness. This guard keeps the classifier from trusting a row - // that ever claimed more than the plan holds. + // say so: only a `Block`-rate parameter is `Live` (`controls::effect_rows`), a `None`-rate + // one is prepared. This guard keeps the classifier from trusting a row that ever claimed + // more than the plan holds. if parameter.automation_rate != AutomationRate::Block { return Err(LiveRebuild::RebuildOnly(RebuildReason::Correctness)); } diff --git a/crates/host-core/tests/effect_observation.rs b/crates/host-core/tests/effect_observation.rs index 09b00aa85..39e79ed7f 100644 --- a/crates/host-core/tests/effect_observation.rs +++ b/crates/host-core/tests/effect_observation.rs @@ -140,6 +140,7 @@ fn prepare_from(document: &str, leg: Leg) -> Session { route_controls: Vec::new(), master_track: None, live_values_superseded: LiveValuesSuperseded::default(), + jump_lengths: None, }; (prepared, handles) } else { diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index fef9a86a2..442bfb565 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -4222,3 +4222,193 @@ fn a_vca_reach_change_beside_a_solo_takes_the_models_ramp() { Some(vec![mute(BuiltinLaneSelector::Both, true, 40)]) ); } + +// Issue #1639: the jump lengths of stored automation, in the plan's cell. + +/// The classification of `current` to `edit(current)` under the post-commit lengths, as a host +/// classifies a transaction. +fn classify_jump( + current: &SessionModel, + edit: impl FnOnce(&mut SessionModel), +) -> (Option, bool, usize) { + let next = edited(current, edit); + let delta = classify_live_delta( + current, + &next, + LiveRamps::for_session(&next), + &EditRamps::new(), + ) + .expect("live"); + assert!( + delta.strips.is_empty() && delta.effects.is_empty() && delta.routes.is_empty(), + "no record beside the jump lengths" + ); + ( + delta.jump_lengths, + delta.is_empty(), + usize::from(delta.settings_changed), + ) +} + +/// `model` with one stored automation, `ride`. +fn automated(model: &SessionModel, ride: impl FnOnce(&SessionModel) -> Automation) -> SessionModel { + let mut model = model.clone(); + let ride = ride(&model); + model.automation.push(ride); + model +} + +/// A mute ride on the first track's builtin strip (builtin 6, left lane). +fn mute_ride(model: &SessionModel) -> Automation { + let mut ride = fader_ride(model); + ride.id = id("mute-ride"); + ride.target.control = 6; + ride.target.lane = ParameterChannel::Left; + ride.segments[0].shape = AutomationShape::Step; + ride.segments[0].unit = ParameterUnit::Linear; + ride.segments[0].start_value = 0.0; + ride.segments[0].end_value = 1.0; + ride +} + +/// #1639 gate 2. A `control_smoothing` change on a session with a fader or a mute entry is a live +/// delta that carries the post-commit lengths and no record; without stored automation, or with +/// only an effect parameter entry, it is empty (`model_only`). A change in milliseconds that rounds +/// to the same sample counts is empty too. An effect's bypass entry reads the `mute_ms` length +/// (its crossfade), so it carries the lengths. +/// +/// Test value: red if the edit is `live` with no entry that reads a length (a needless write), +/// `model_only` with one (the new lengths never reach render), or if equal sample counts still +/// write. +#[test] +fn a_control_smoothing_change_writes_the_jump_lengths_only_for_automation_that_reads_them() { + let fast = ControlSmoothing { + mute_ms: 1.0, + fader_ms: 2.0, + pan_ms: 3.0, + }; + let base = two_tracks(); + let expected = host_core::GraphJumpLengths { + fader_samples: 96, + mute_samples: 48, + pan_samples: 144, + }; + for (name, current) in [ + ("fader entry", automated(&base, fader_ride)), + ("mute entry", automated(&base, mute_ride)), + ( + "pan entry", + automated(&base, |model| pan_ride(model, false)), + ), + ] { + assert!( + controls::automation_diagnostics(¤t, current.sample_rate_hz).is_empty(), + "{name}: a valid entry" + ); + assert_eq!( + classify_jump(¤t, |next| next.control_smoothing = Some(fast)), + (Some(expected), false, 1), + "{name}: live with the new lengths" + ); + } + + // No stored automation: model-only. + assert_eq!( + classify_jump(&base, |next| next.control_smoothing = Some(fast)), + (None, true, 1), + "no automation" + ); + + // Only an effect parameter entry: its ramp is the effect's own, so model-only. + let mut effects = with_effects(); + effects.tracks[0].inserts.effects[2].params = Vec::new(); + let effects = normalized(&effects); + let gain = automated(&effects, |model| eq_automation(model, 4, ParameterUnit::Db)); + assert!(controls::automation_diagnostics(&gain, gain.sample_rate_hz).is_empty()); + assert_eq!( + classify_jump(&gain, |next| next.control_smoothing = Some(fast)), + (None, true, 1), + "an effect parameter entry" + ); + + // An effect's bypass entry: its crossfade is the `mute_ms` length. + let bypass = automated(&effects, |model| { + let mut ride = eq_automation(model, controls::BYPASS_CONTROL, ParameterUnit::Linear); + ride.target.section = Section::Inserts(id("comp")); + ride.target.lane = ParameterChannel::Both; + ride.segments[0].start_value = 0.0; + ride + }); + assert!(controls::automation_diagnostics(&bypass, bypass.sample_rate_hz).is_empty()); + assert_eq!( + classify_jump(&bypass, |next| next.control_smoothing = Some(fast)), + (Some(expected), false, 1), + "an effect bypass entry" + ); + + // 10.0 ms and 10.001 ms are both 480 samples at 48 kHz: no write. + let ten = ControlSmoothing { + mute_ms: 10.0, + fader_ms: 10.0, + pan_ms: 10.0, + }; + let current = with_smoothing(&automated(&base, fader_ride), Some(ten)); + assert_eq!(current.sample_rate_hz, 48_000); + assert_eq!( + classify_jump(¤t, |next| { + next.control_smoothing = Some(ControlSmoothing { + fader_ms: 10.001, + ..ten + }); + }), + (None, true, 1), + "equal sample counts" + ); +} + +/// #1639 D3: the rows whose jumps read a length are the rows `LiveRamps::for_row` gives a length: +/// a builtin fader, trim, mute, polarity, pan or matrix row, a strip's own and a VCA's ramped rows, +/// and an effect's bypass; never a filter, the delay or an effect parameter. +/// +/// Test value: red if the row set is a hand-written list that drifts from the descriptors' ramp +/// rows, or if an effect's parameter is taken for its bypass. +#[test] +fn the_rows_that_read_a_jump_length_are_the_ramped_rows() { + let strip = |section: Section, control: u32| ControlAddress { + owner: Owner::Strip(id("track")), + section, + control, + lane: ParameterChannel::Left, + }; + let builtin = |control| host_core::automation_jump_row(&strip(Section::Builtins, control)); + assert_eq!(builtin(1), Some(LiveRampRow::PolarityInvert)); + assert_eq!(builtin(2), Some(LiveRampRow::InputTrim)); + assert_eq!(builtin(3), None, "the HPF keeps its fixed ramp"); + assert_eq!(builtin(4), None, "the LPF keeps its fixed ramp"); + assert_eq!(builtin(5), Some(LiveRampRow::Fader)); + assert_eq!(builtin(6), Some(LiveRampRow::Mute)); + for matrix in 7..=10 { + assert_eq!(builtin(matrix), Some(LiveRampRow::Matrix)); + } + assert_eq!(builtin(11), None, "the delay is not live"); + assert_eq!(builtin(12), Some(LiveRampRow::Pan)); + let effect = + |control| host_core::automation_jump_row(&strip(Section::Inserts(id("eq")), control)); + assert_eq!( + effect(controls::BYPASS_CONTROL), + Some(LiveRampRow::BypassCrossfade) + ); + assert_eq!(effect(4), None, "an effect parameter"); + // A VCA's own rows: its offset reads `fader_ms`, its mute `mute_ms`. + let vca: Vec = (0..64) + .filter_map(|control| { + host_core::automation_jump_row(&ControlAddress { + owner: Owner::Vca(id("vca")), + section: Section::Own, + control, + lane: ParameterChannel::Both, + }) + }) + .collect(); + assert_eq!(vca, [LiveRampRow::VcaOffset, LiveRampRow::VcaMute]); +} diff --git a/crates/host-core/tests/live_routes.rs b/crates/host-core/tests/live_routes.rs index 82b0b5744..12c309844 100644 --- a/crates/host-core/tests/live_routes.rs +++ b/crates/host-core/tests/live_routes.rs @@ -327,6 +327,7 @@ impl Live { route_controls: Vec::new(), master_track: None, live_values_superseded: host_core::LiveValuesSuperseded::default(), + jump_lengths: None, }; (host, handles) }; @@ -1041,13 +1042,13 @@ fn live_send_lanes_are_charged_against_the_graph_cap() { idle.host.report.route_control_resources, RouteControlResources::default() ); + // The jump-length cell, #1639 D1: an idle preparation's cell reader holds the count it made + // too, so both charge it and only the route lanes separate them. assert_eq!( - admitted(&idle.host) - + charged.total_bytes - + host_core::LiveValuesSuperseded::retained_bytes(), + admitted(&idle.host) + charged.total_bytes, total, - "only the route lanes and the count the strips' cell readers hold separate the two charges: \ - an idle preparation has no reader, so it retains and charges no count" + "only the route lanes separate the two charges: both plans' jump-length cells hold their \ + preparation's count" ); } diff --git a/crates/host-core/tests/prepare.rs b/crates/host-core/tests/prepare.rs index 10f71de87..4952f7889 100644 --- a/crates/host-core/tests/prepare.rs +++ b/crates/host-core/tests/prepare.rs @@ -764,11 +764,11 @@ fn dense_refusal_diagnostics_finish_under_one_second_in_release() { } /// The graph row counts the allocations the plan makes for itself, for every host: its revision gate -/// (#1502 D1) always, and its `live_values_superseded` count only where the count is both the -/// preparation's own and held by the plan (#1312 D2). A host that passes the session's count -/// charges it once itself (the C ABI), and a preparation with no strip controls has no cell reader -/// holding a count, so the plan retains none. The browser's per-plan count has readers and copies -/// this row unchanged into its exact retained total. +/// (#1502 D1) and its jump-length cell (#1639 D1) always, and its `live_values_superseded` count +/// where the count is the preparation's own (#1312 D2): the jump-length cell's reader holds it in +/// every plan, with or without strip controls. A host that passes the session's count charges it +/// once itself (the C ABI). The browser's per-plan count copies this row unchanged into its exact +/// retained total. /// /// The graph compiler's row is `session_bytes + incremental_plan_bytes`, and the facade passes it /// no session bytes, so the report's incremental row is the independent term the plan's own @@ -777,7 +777,9 @@ fn dense_refusal_diagnostics_finish_under_one_second_in_release() { fn the_graph_row_counts_the_plans_gate_and_a_count_it_made_and_holds() { let compiled = compile_host_session(SESSION, &caps()).expect("compiled fixture"); let with_lanes = HostLiveControlRequest::default(); - let gate = engine::realtime::plan_revision_gate_retained_bytes(); + // The jump-length cell, #1639 D1: every plan holds it. + let gate = + engine::realtime::plan_revision_gate_retained_bytes() + graph::jump_length_retained_bytes(); let row = |report: &HostPrepareReport| { report.graph_session_plus_plan_bytes - report.graph_incremental_plan_bytes }; @@ -787,9 +789,9 @@ fn the_graph_row_counts_the_plans_gate_and_a_count_it_made_and_holds() { .report; assert_eq!( row(&lane_free), - gate, - "no strip controls: no cell reader holds the preparation's count, so the row charges the \ - gate and not the count" + gate + LiveValuesSuperseded::retained_bytes(), + "no strip controls: the jump-length cell's reader holds the preparation's count, so the row \ + charges the gate, the cell and the count" ); let own = prepare_host_runtime_with_live_controls(&compiled, &caps(), &with_lanes) .unwrap_or_else(|_| panic!("prepare with the preparation's own count")) diff --git a/crates/protocol/src/controller.rs b/crates/protocol/src/controller.rs index a7cf0aed9..85bfafcf1 100644 --- a/crates/protocol/src/controller.rs +++ b/crates/protocol/src/controller.rs @@ -662,8 +662,6 @@ pub struct MockProvider { diagnostics: Vec, /// Current absolute state. transport_state: TransportState, - /// Current absolute position. - transport_position: SampleTime, /// Last endpoint-local complete typed telemetry configuration. telemetry_configuration: TelemetryConfiguration, /// How many times `response_get` ran: a replayed read never runs again. @@ -684,7 +682,6 @@ impl Default for MockProvider { }, diagnostics: Vec::new(), transport_state: TransportState::Stopped, - transport_position: SampleTime(0), telemetry_configuration: TelemetryConfiguration { meter_handles: Vec::new(), meter_period_blocks: 0, @@ -905,15 +902,11 @@ impl ControlProvider for MockProvider { fn transport_get(&mut self) -> TransportSnapshot { TransportSnapshot { state: self.transport_state, - position: self.transport_position, effective_sample: self.current_sample, } } fn transport_set(&mut self, request: TransportSetRequest) -> TransportSnapshot { self.transport_state = request.state; - if let Some(position) = request.position { - self.transport_position = position; - } self.transport_get() } fn telemetry_configure( @@ -3017,7 +3010,6 @@ impl ProtocolController

{ ReliablePayload::TransportState { event_sequence, state, - position, effective_sample, origin_request_id, } => self.codec.encode_event_frame_into( @@ -3026,7 +3018,6 @@ impl ProtocolController

{ payload: EventPayload::TransportState(TransportStateEvent { event_sequence, state, - position, effective_sample, origin_request_id, }), @@ -3318,7 +3309,6 @@ impl ProtocolController

{ self.session.revision(), event_sequence, snapshot.state, - snapshot.position, snapshot.effective_sample, Some(request.request_id), ), @@ -3773,7 +3763,6 @@ impl ProtocolController

{ let ReliablePayload::TransportState { event_sequence, state, - position, effective_sample, origin_request_id, } = slot.payload @@ -3787,7 +3776,6 @@ impl ProtocolController

{ TransportStateEvent { event_sequence, state, - position, effective_sample, origin_request_id, }, diff --git a/crates/protocol/src/controller/tests.rs b/crates/protocol/src/controller/tests.rs index 569d04ffb..0371e3d18 100644 --- a/crates/protocol/src/controller/tests.rs +++ b/crates/protocol/src/controller/tests.rs @@ -312,10 +312,11 @@ fn public_b1b_uses_exactly_the_typed_reader_passes_and_replays_identical_bytes() // One below the fixture's own edit count: #241 took the corpus from 42 edits to 39 by // deleting opcodes 0x0006/0x0102/0x0104, #1094 appended `SetConsole` and // `SetTrackConsole`, #1241 the VCA edits and #1553 `SetControl`, and decision 17 (#1561) - // retired thirteen value ops, so the boundary this row exists to probe is now 34. A smaller - // number still refuses, but it stops being a boundary. - assert_eq!(conformance::complete_all_opcode_fixture().len(), 35); - limited.config.maximum_transaction_edits = 34; + // retired thirteen value ops, and #1678 appended a second `SetControl` (an insert row with an + // expected effect), so the boundary this row exists to probe is now 35. A smaller number still + // refuses, but it stops being a boundary. + assert_eq!(conformance::complete_all_opcode_fixture().len(), 36); + limited.config.maximum_transaction_edits = 35; assert_eq!( limited.process_b1b_btlv( &transaction.bytes, @@ -595,7 +596,6 @@ fn full_frame_ingress_dispatches_every_registered_command_through_typed_frames() revision, crate::CommandPayload::TransportSet(TransportSetRequest { state: TransportState::Playing, - position: Some(SampleTime(48)), }), ), ), @@ -655,7 +655,6 @@ fn full_frame_ingress_replays_exact_bytes_and_rejects_changed_request_reuse() { ExpectedRevision::Exact(SessionRevision(7)), crate::CommandPayload::TransportSet(TransportSetRequest { state: TransportState::Playing, - position: Some(SampleTime(48)), }), ); let mut endpoint = controller(8); @@ -1602,6 +1601,7 @@ fn builtin_edit( fn solo_edit(track: &session::StableId, on: bool, ramp: Option) -> SessionEdit { SessionEdit::SetControl { + expected_effect: None, address: crate::control_core::ControlAddress { owner: crate::control_core::Owner::Strip(track.clone()), section: crate::control_core::Section::Own, @@ -2154,7 +2154,7 @@ fn a_ramp_length_out_of_range_answers_its_own_code_at_the_edit_path() { #[test] fn retired_and_console_refused_codes_meet_their_conformance_rows() { let rows = conformance::retired_code_rows(); - assert_eq!(rows.len(), 16 + 4 + 7 + 13 + 1 + 4 + 10 + 1); + assert_eq!(rows.len(), 16 + 4 + 7 + 13 + 1 + 4 + 10 + 2 + 1); for row in rows { let mut endpoint = controller_over(conformance::console_session_fixture(), 8, SampleTime(0)); @@ -2234,6 +2234,130 @@ fn retired_and_console_refused_codes_meet_their_conformance_rows() { } } +/// #1635 gate 1. A `TRANSPORT_SET` that still carries the retired position (field 2), in either +/// flag form, is refused at decode: the transport state, the revision, the session and the +/// reliable lane stay as they were, and no response frame acknowledges it. +/// +/// Red if a peer can still store a position, if the field is skipped as an unknown optional field +/// and the state set anyway (a playing readback), or if the refusal moves state before it refuses. +#[test] +fn a_transport_set_with_the_retired_position_changes_nothing() { + let rows = conformance::retired_code_rows() + .into_iter() + .filter(|row| row.name.starts_with("retired.transport_set_field_2.")) + .collect::>(); + assert_eq!(rows.len(), 2, "one row per flag form"); + for row in rows { + let mut endpoint = controller(8); + let snapshot = endpoint.session().canonical_snapshot().to_owned(); + let response = process_full_command(&mut endpoint, &row.bytes); + let mut fields = [0_u16; 64]; + let crate::DecodedTypedResponseFrame::NonOk { header, .. } = ProtocolCodec::default() + .decode_typed_response(&response, &mut DecodeScratch::new(&mut fields)) + .unwrap_or_else(|error| panic!("{}: correlatable response: {error:?}", row.name)) + else { + panic!("{}: a set with a position was acknowledged", row.name); + }; + assert_eq!(header.status as u16, row.status, "{}", row.name); + assert_eq!(endpoint.session().revision(), SessionRevision(7)); + assert_eq!(endpoint.session().canonical_snapshot(), snapshot); + assert_eq!( + endpoint + .queues() + .report(crate::QueueKind::ReliableEvent) + .occupancy, + 0, + "{}: no TRANSPORT_STATE event", + row.name + ); + let get = process_full_command( + &mut endpoint, + &full_command( + 2, + ExpectedRevision::Any, + crate::CommandPayload::TransportGet, + ), + ); + let crate::DecodedTypedResponseFrame::Success { header, payload } = + ProtocolCodec::default() + .decode_typed_response(&get, &mut DecodeScratch::new(&mut fields)) + .expect("transport get response") + else { + panic!("{}: TRANSPORT_GET failed", row.name); + }; + assert_eq!(header.message_id, MessageId::TransportGet); + let crate::DecodedSuccessResponsePayload::TransportGetSnapshot(snapshot) = payload else { + panic!("{}: not a transport snapshot", row.name); + }; + assert_eq!(snapshot.state, TransportState::Stopped, "{}", row.name); + } +} + +/// #1635 gate 2. `TRANSPORT_SET` still works without the position: it answers a snapshot with the +/// requested state and the effective sample and queues exactly one `TRANSPORT_STATE` event that +/// carries the same two values. +/// +/// Red if removing the position breaks the state round trip, the effective sample, or the event. +#[test] +fn a_transport_set_answers_the_state_and_queues_one_event_with_it() { + let mut endpoint = controller_over( + parse_session_json(EXAMPLE).expect("fixture"), + 8, + SampleTime(96), + ); + let response = process_full_command( + &mut endpoint, + &full_command( + 1, + ExpectedRevision::Exact(SessionRevision(7)), + crate::CommandPayload::TransportSet(TransportSetRequest { + state: TransportState::Playing, + }), + ), + ); + let mut fields = [0_u16; 64]; + let crate::DecodedTypedResponseFrame::Success { header, payload } = ProtocolCodec::default() + .decode_typed_response(&response, &mut DecodeScratch::new(&mut fields)) + .expect("transport set response") + else { + panic!("TRANSPORT_SET failed"); + }; + assert_eq!(header.message_id, MessageId::TransportSet); + let crate::DecodedSuccessResponsePayload::TransportSetSnapshot(snapshot) = payload else { + panic!("not a transport set snapshot"); + }; + assert_eq!( + snapshot, + TransportSnapshot { + state: TransportState::Playing, + effective_sample: SampleTime(96), + } + ); + assert_eq!( + endpoint + .queues() + .report(crate::QueueKind::ReliableEvent) + .occupancy, + 1 + ); + let slot = endpoint.queues_mut().try_dequeue_event().expect("event"); + assert_eq!(slot.message_id, MessageId::TransportState); + let mut event = [0_u8; 64]; + let length = endpoint + .encode_transport_state_event(slot, &mut event) + .expect("event payload"); + assert_eq!( + ProtocolCodec::default().decode_transport_state_event(&event[..length], 4), + Ok(TransportStateEvent { + event_sequence: 1, + state: TransportState::Playing, + effective_sample: SampleTime(96), + origin_request_id: Some(id(1)), + }) + ); + assert!(endpoint.queues_mut().try_dequeue_event().is_err()); +} + /// The acked-batch question for the console (#1094): a transaction that changes the slot set but /// skips a track's entries is refused before any acknowledgement is written, and a peer that /// retries the same bytes gets the same refusal from the replay cache, never an ack. @@ -2769,7 +2893,7 @@ fn b3a_transport_btlv_is_typed_idempotent_and_emits_reliable_state() { wire_revision, flags, payload_len as u32, - if request.position.is_some() { 2 } else { 1 }, + 1, ) .expect("header"); codec @@ -2795,7 +2919,6 @@ fn b3a_transport_btlv_is_typed_idempotent_and_emits_reliable_state() { ); let set = TransportSetRequest { state: TransportState::Playing, - position: Some(SampleTime(9)), }; let set_bytes = set_frame(2, ExpectedRevision::Exact(SessionRevision(7)), set); let mut controller = controller(8); @@ -2805,11 +2928,10 @@ fn b3a_transport_btlv_is_typed_idempotent_and_emits_reliable_state() { assert_eq!(get_response.status, StatusCode::Ok); assert_eq!( codec - .decode_transport_snapshot(get_response.payload(), 3) + .decode_transport_snapshot(get_response.payload(), 2) .expect("get snapshot"), TransportSnapshot { state: TransportState::Stopped, - position: SampleTime(0), effective_sample: SampleTime(0), } ); @@ -2819,11 +2941,10 @@ fn b3a_transport_btlv_is_typed_idempotent_and_emits_reliable_state() { assert_eq!(set_response.status, StatusCode::Ok); assert_eq!( codec - .decode_transport_snapshot(set_response.payload(), 3) + .decode_transport_snapshot(set_response.payload(), 2) .expect("set snapshot"), TransportSnapshot { state: TransportState::Playing, - position: SampleTime(9), effective_sample: SampleTime(0), } ); @@ -2831,22 +2952,21 @@ fn b3a_transport_btlv_is_typed_idempotent_and_emits_reliable_state() { .queues_mut() .try_dequeue_event() .expect("transport event"); - let mut event_payload = [0_u8; 80]; + let mut event_payload = [0_u8; 64]; assert_eq!( controller.encode_transport_state_event(event, &mut event_payload), - Ok(80) + Ok(64) ); assert_eq!( - codec.decode_transport_state_event(&event_payload, 5), + codec.decode_transport_state_event(&event_payload, 4), Ok(TransportStateEvent { event_sequence: 1, state: TransportState::Playing, - position: SampleTime(9), effective_sample: SampleTime(0), origin_request_id: Some(id(2)), }) ); - let mut event_frame = [0_u8; crate::OUTER_HEADER_BYTES + 80]; + let mut event_frame = [0_u8; crate::OUTER_HEADER_BYTES + 64]; codec .write_outer_header( &mut event_frame, @@ -2856,38 +2976,35 @@ fn b3a_transport_btlv_is_typed_idempotent_and_emits_reliable_state() { 0, 7, 0, - 80, - 5, + 64, + 4, ) .expect("event header"); event_frame[crate::OUTER_HEADER_BYTES..].copy_from_slice(&event_payload); assert_eq!( hex(&event_frame), concat!( - "4d49534f43544c0001000000300003001080000050000000", - "000000000000000007000000000000000500000000000000", + "4d49534f43544c0001000000300003001080000040000000", + "000000000000000007000000000000000400000000000000", "01000401080000000100000000000000", "02000101010000000200000000000000", - "03000401080000000900000000000000", "04000401080000000000000000000000", "05000400080000000200000000000000" ) ); - let retain = TransportSetRequest { + let stop = TransportSetRequest { state: TransportState::Stopped, - position: None, }; let retained = controller .process_b1b_btlv( - &set_frame(3, ExpectedRevision::Exact(SessionRevision(7)), retain), + &set_frame(3, ExpectedRevision::Exact(SessionRevision(7)), stop), &mut DecodeScratch::new(&mut [0_u16; 1]), ) - .expect("retain position"); + .expect("stop"); assert_eq!( - codec.decode_transport_snapshot(retained.payload(), 3), + codec.decode_transport_snapshot(retained.payload(), 2), Ok(TransportSnapshot { state: TransportState::Stopped, - position: SampleTime(9), effective_sample: SampleTime(0), }) ); @@ -2903,7 +3020,6 @@ fn b3a_transport_btlv_is_typed_idempotent_and_emits_reliable_state() { TransportState::Stopped, "a full reliable-event queue must leave the typed provider unchanged" ); - assert_eq!(controller.provider().transport_position, SampleTime(9)); let invalid_revision = set_frame(5, ExpectedRevision::Any, set); assert_eq!( controller @@ -2952,7 +3068,6 @@ fn a_revision_change_and_a_locate_reserve_and_emit_only_their_own_events() { command: ControlCommand::TransportSet { request: TransportSetRequest { state: TransportState::Playing, - position: Some(SampleTime(10)), }, }, }); @@ -3764,7 +3879,6 @@ fn c2b2_event_egress_emits_all_five_schema_closed_full_frames() { revision, 2, TransportState::Playing, - SampleTime(8), SampleTime(12), Some(id(11)), ); @@ -3837,7 +3951,6 @@ fn c2b2_event_egress_emits_all_five_schema_closed_full_frames() { payload: EventPayload::TransportState(TransportStateEvent { event_sequence: 2, state: TransportState::Playing, - position: SampleTime(8), effective_sample: SampleTime(12), origin_request_id: Some(id(11)), }), @@ -3955,14 +4068,8 @@ fn c2b2_short_buffers_retain_pending_reliable_order_and_diagnostic_storage() { Err(EventEgressError::Encode(EncodeError::OutputTooSmall { required })) if required > short.len() )); assert_eq!(short, [0xa5]); - let second = ReliableSlot::transport_state( - revision, - 2, - TransportState::Stopped, - SampleTime(0), - SampleTime(0), - None, - ); + let second = + ReliableSlot::transport_state(revision, 2, TransportState::Stopped, SampleTime(0), None); controller .queues_mut() .try_enqueue_event(second) diff --git a/crates/protocol/src/message_wire.rs b/crates/protocol/src/message_wire.rs index 887df63b2..4d937f051 100644 --- a/crates/protocol/src/message_wire.rs +++ b/crates/protocol/src/message_wire.rs @@ -12,7 +12,7 @@ use crate::{ }, schema::{ self, control_address, control_entry, control_list_page, control_list_request, - control_state_page, enum_choice, + control_state_page, enum_choice, set_control_address, }, }; @@ -851,13 +851,12 @@ pub enum TransportState { Playing = 2, } -/// Typed `TRANSPORT_SET` command. An absent position retains the provider's current position. +/// Typed `TRANSPORT_SET` command: the absolute target state. The stored position is retired +/// (#1635); the engine has one playhead, which the host moves with source seeks. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct TransportSetRequest { /// Absolute target state. pub state: TransportState, - /// Absolute target position, when changing it. - pub position: Option, } /// Typed `TRANSPORT_GET`/`TRANSPORT_SET` success payload. @@ -865,8 +864,6 @@ pub struct TransportSetRequest { pub struct TransportSnapshot { /// Effective absolute state. pub state: TransportState, - /// Effective absolute position. - pub position: crate::SampleTime, /// Engine sample at which this observation/change is effective. pub effective_sample: crate::SampleTime, } @@ -878,8 +875,6 @@ pub struct TransportStateEvent { pub event_sequence: u64, /// Effective absolute state. pub state: TransportState, - /// Effective absolute position. - pub position: crate::SampleTime, /// Effective engine sample. pub effective_sample: crate::SampleTime, /// Originating set request, when this state change came from a command. @@ -1612,8 +1607,8 @@ impl ProtocolCodec { } /// Return the exact caller-output size for a typed absolute transport set request. - pub const fn encoded_transport_set_request_len(&self, value: TransportSetRequest) -> usize { - if value.position.is_some() { 32 } else { 16 } + pub const fn encoded_transport_set_request_len(&self, _value: TransportSetRequest) -> usize { + 16 } /// Encode a typed absolute transport-state set request without allocation. @@ -1637,21 +1632,20 @@ impl ProtocolCodec { payload: &[u8], count: u32, ) -> Result { - let fields = Message::top_level(payload, count, self.limits())? - .schema_spec(&schema::transport_set::SPEC)?; + // Field 2, the stored position, is retired (#1635): refused in either flag form. + let fields = Message::top_level(payload, count, self.limits())?.schema_spec_retiring( + &schema::transport_set::SPEC, + &schema::transport_set::RETIRED, + )?; Ok(TransportSetRequest { state: parse_transport_state(read_u8(one_spec!( fields, schema::transport_set::STATE )?)?)?, - position: optional_spec!(fields, schema::transport_set::POSITION)? - .map(read_u64) - .transpose()? - .map(crate::SampleTime), }) } - /// Encode a typed three-field transport snapshot directly into caller output. + /// Encode a typed two-field transport snapshot directly into caller output. pub fn encode_transport_snapshot( &self, value: TransportSnapshot, @@ -1668,23 +1662,22 @@ impl ProtocolCodec { checked_writer_len(&writer, required) } - /// Strictly decode a typed three-field transport snapshot without allocation. + /// Strictly decode a typed two-field transport snapshot without allocation. pub fn decode_transport_snapshot( &self, payload: &[u8], count: u32, ) -> Result { - let fields = Message::top_level(payload, count, self.limits())? - .schema_spec(&schema::transport_snapshot::SPEC)?; + // Field 2, the stored position, is retired (#1635). + let fields = Message::top_level(payload, count, self.limits())?.schema_spec_retiring( + &schema::transport_snapshot::SPEC, + &schema::transport_snapshot::RETIRED, + )?; Ok(TransportSnapshot { state: parse_transport_state(read_u8(one_spec!( fields, schema::transport_snapshot::STATE )?)?)?, - position: crate::SampleTime(read_u64(one_spec!( - fields, - schema::transport_snapshot::POSITION - )?)?), effective_sample: crate::SampleTime(read_u64(one_spec!( fields, schema::transport_snapshot::EFFECTIVE_SAMPLE @@ -1695,9 +1688,9 @@ impl ProtocolCodec { /// Return the exact caller-output size for a reliable typed transport-state event. pub const fn encoded_transport_state_event_len(&self, value: TransportStateEvent) -> usize { if value.origin_request_id.is_some() { - 80 - } else { 64 + } else { + 48 } } @@ -1722,8 +1715,11 @@ impl ProtocolCodec { payload: &[u8], count: u32, ) -> Result { - let fields = Message::top_level(payload, count, self.limits())? - .schema_spec(&schema::transport_state_event::SPEC)?; + // Field 3, the stored position, is retired (#1635). + let fields = Message::top_level(payload, count, self.limits())?.schema_spec_retiring( + &schema::transport_state_event::SPEC, + &schema::transport_state_event::RETIRED, + )?; Ok(TransportStateEvent { event_sequence: read_u64(one_spec!( fields, @@ -1733,10 +1729,6 @@ impl ProtocolCodec { fields, schema::transport_state_event::STATE )?)?)?, - position: crate::SampleTime(read_u64(one_spec!( - fields, - schema::transport_state_event::POSITION - )?)?), effective_sample: crate::SampleTime(read_u64(one_spec!( fields, schema::transport_state_event::EFFECTIVE_SAMPLE @@ -2550,19 +2542,8 @@ pub(crate) fn write_transport_set( sink: &mut dyn Sink, value: TransportSetRequest, ) -> Result<(), EncodeError> { - sink.check_field_count(schema::transport_set::SPEC.field_count(&[( - schema::transport_set::POSITION, - usize::from(value.position.is_some()), - )])?)?; - write_spec!(sink, schema::transport_set::STATE, &[value.state as u8])?; - if let Some(position) = value.position { - write_spec!( - sink, - schema::transport_set::POSITION, - &position.0.to_le_bytes() - )?; - } - Ok(()) + sink.check_field_count(schema::transport_set::SPEC.field_count(&[])?)?; + write_spec!(sink, schema::transport_set::STATE, &[value.state as u8]) } pub(crate) fn write_transport_snapshot( @@ -2575,11 +2556,6 @@ pub(crate) fn write_transport_snapshot( schema::transport_snapshot::STATE, &[value.state as u8] )?; - write_spec!( - sink, - schema::transport_snapshot::POSITION, - &value.position.0.to_le_bytes() - )?; write_spec!( sink, schema::transport_snapshot::EFFECTIVE_SAMPLE, @@ -2605,11 +2581,6 @@ pub(crate) fn write_transport_state_event( schema::transport_state_event::STATE, &[value.state as u8] )?; - write_spec!( - sink, - schema::transport_state_event::POSITION, - &value.position.0.to_le_bytes() - )?; write_spec!( sink, schema::transport_state_event::EFFECTIVE_SAMPLE, @@ -3169,27 +3140,45 @@ fn write_control_address(sink: &mut dyn Sink, address: &ControlAddress) -> Resul }) } -/// The fields of one address message, the one encoder of the address: the control list's rows and -/// the `SET_CONTROL` edit both write it. The caller has validated the address. +/// The fields of one list-style address message: the control list's rows and a response target +/// write it. The caller has validated the address. fn write_control_address_fields( sink: &mut dyn Sink, address: &ControlAddress, ) -> Result<(), EncodeError> { - let fields = control_address::SPEC.field_count(&[ - ( - control_address::OWNER_ID, - usize::from(address.owner_id.is_some()), - ), - ( - control_address::SLOT_ID, - usize::from(address.slot_id.is_some()), - ), - ( - control_address::CONTROL, - usize::from(address.control.is_some()), - ), - (control_address::LANE, usize::from(address.lane.is_some())), - ])?; + write_address_fields(sink, &control_address::SPEC, address, None) +} + +/// The one encoder of the address fields: `spec` is the list's address message, or the set-control +/// address message with `expected_effect` as its seventh field. The caller has validated the +/// address. +fn write_address_fields( + sink: &mut dyn Sink, + spec: &'static schema::MessageSpec, + address: &ControlAddress, + expected_effect: Option<&str>, +) -> Result<(), EncodeError> { + let mut variable = [(control_address::OWNER_ID, 0); 5]; + variable[0] = ( + control_address::OWNER_ID, + usize::from(address.owner_id.is_some()), + ); + variable[1] = ( + control_address::SLOT_ID, + usize::from(address.slot_id.is_some()), + ); + variable[2] = ( + control_address::CONTROL, + usize::from(address.control.is_some()), + ); + variable[3] = (control_address::LANE, usize::from(address.lane.is_some())); + let mut count = 4; + if let Some(expected) = expected_effect { + debug_assert!(!expected.is_empty()); + variable[count] = (set_control_address::EXPECTED_EFFECT, 1); + count += 1; + } + let fields = spec.field_count(&variable[..count])?; sink.message_header(fields)?; write_spec!( sink, @@ -3213,13 +3202,20 @@ fn write_control_address_fields( &[schema::parameter_channel_wire(lane)] )?; } + if let Some(expected) = expected_effect { + write_spec!( + sink, + set_control_address::EXPECTED_EFFECT, + expected.as_bytes() + )?; + } Ok(()) } -/// The address message of a `SET_CONTROL` edit (the body of its `address` field): the control -/// list's one address encoding, with `control` and `lane` present. An address that breaks the -/// list's rules is `InvalidField`. -pub(crate) fn write_set_control_address( +/// The address message of a stored automation target: the control list's one address encoding with +/// `control` and `lane` present, and no expected effect (the target is a row, not an edit). An +/// address that breaks the list's rules is `InvalidField`. +pub(crate) fn write_automation_target_address( sink: &mut dyn Sink, address: &controls::ControlAddress, ) -> Result<(), EncodeError> { @@ -3230,20 +3226,77 @@ pub(crate) fn write_set_control_address( write_control_address_fields(sink, &address) } -/// The address message of a `SET_CONTROL` edit, decoded by the control list's one address decoder -/// and held to the list's rules for a control row: every refusal is `InvalidField`, an -/// unallocated owner, section or lane code included. -pub(crate) fn decode_set_control_address( +/// A stored automation target, decoded by the control list's one address decoder and held to the +/// list's rules for a control row: every refusal is `InvalidField`, an unallocated owner, section +/// or lane code included. A target holds no expected effect in any section: the `SET_CONTROL` +/// address's seventh field is refused here as `SET_CONTROL` refuses it outside an effect section +/// (`InvalidField` in its optional form, `InvalidTlv` in its mandatory form), never skipped as an +/// unknown optional field. +pub(crate) fn decode_automation_target_address( message: Message<'_>, ) -> Result { let limits = message.limits(); - let address = decode_control_address(limits, message)?; + let message = message.schema_spec(&set_control_address::SPEC)?; + if optional_spec!(message, set_control_address::EXPECTED_EFFECT)?.is_some() { + return Err(DecodeError::InvalidField); + } + let address = decode_control_address_fields(limits, &message)?; if !control_address_is_valid(limits, &address, ControlRowKind::Control) { return Err(DecodeError::InvalidField); } controls::ControlAddress::try_from(&address) } +/// The address message of a `SET_CONTROL` edit (the body of its `address` field): the control +/// list's one address encoding, with `control` and `lane` present, and the native effect the edit +/// was written for as an optional seventh field (#1678). An address that breaks the list's rules, +/// or an expected effect on a section that runs no effect, is `InvalidField`. +pub(crate) fn write_set_control_edit_address( + sink: &mut dyn Sink, + address: &controls::ControlAddress, + expected_effect: Option<&session::StableId>, +) -> Result<(), EncodeError> { + let address = ControlAddress::from(address); + if !control_address_is_valid(sink.limits(), &address, ControlRowKind::Control) + || expected_effect.is_some_and(|effect| { + !has_slot(address.section) || !stable_id_fits(sink.limits(), effect.as_str()) + }) + { + return Err(EncodeError::InvalidField); + } + write_address_fields( + sink, + &set_control_address::SPEC, + &address, + expected_effect.map(session::StableId::as_str), + ) +} + +/// The address message of a `SET_CONTROL` edit, decoded by the control list's one address decoder +/// and held to the list's rules for a control row: every refusal is `InvalidField`, an +/// unallocated owner, section or lane code included. The second value is the native effect the edit +/// was written for (#1678); it is refused unless the section is an `inserts` or `console` one. +pub(crate) fn decode_set_control_edit_address( + message: Message<'_>, +) -> Result<(controls::ControlAddress, Option), DecodeError> { + let limits = message.limits(); + let message = message.schema_spec(&set_control_address::SPEC)?; + let address = decode_control_address_fields(limits, &message)?; + if !control_address_is_valid(limits, &address, ControlRowKind::Control) { + return Err(DecodeError::InvalidField); + } + let expected = optional_spec!(message, set_control_address::EXPECTED_EFFECT)? + .map(|value| { + let text = read_string_within(limits, value)?; + if !has_slot(address.section) { + return Err(DecodeError::InvalidField); + } + session::StableId::parse(text).ok_or(DecodeError::InvalidField) + }) + .transpose()?; + Ok((controls::ControlAddress::try_from(&address)?, expected)) +} + fn write_control_entry(sink: &mut dyn Sink, entry: &ControlEntry) -> Result<(), EncodeError> { use control_entry as e; let (header, route, effect, row) = match &entry.body { @@ -3415,6 +3468,15 @@ fn decode_control_address( message: Message<'_>, ) -> Result { let message = message.schema_spec(&control_address::SPEC)?; + decode_control_address_fields(limits, &message) +} + +/// The fields of an address message whose schema is already selected: the list's address or the +/// set-control address, which share fields 1 to 6. +fn decode_control_address_fields( + limits: crate::ProtocolLimits, + message: &Message<'_>, +) -> Result { let text = |spec: schema::FieldSpec| { optional_spec!(message, spec)? .map(|value| read_string_within(limits, value).map(str::to_owned)) diff --git a/crates/protocol/src/message_wire/tests.rs b/crates/protocol/src/message_wire/tests.rs index 63f8565ef..01a4b9079 100644 --- a/crates/protocol/src/message_wire/tests.rs +++ b/crates/protocol/src/message_wire/tests.rs @@ -1919,58 +1919,72 @@ fn b3a_transport_goldens_truncation_and_direct_codec_are_strict() { let set = TransportSetRequest { state: TransportState::Playing, - position: Some(crate::SampleTime(2)), }; - let mut set_bytes = [0_u8; 32]; + let mut set_bytes = [0_u8; 16]; + assert_eq!(codec.encoded_transport_set_request_len(set), 16); codec .encode_transport_set_request(set, &mut set_bytes) .expect("set encode"); - assert_eq!( - hex(&set_bytes), - concat!( - "01000101010000000200000000000000", - "02000400080000000200000000000000" - ) - ); - assert_eq!(codec.decode_transport_set_request(&set_bytes, 2), Ok(set)); - let mut short = [0xaa; 31]; + assert_eq!(hex(&set_bytes), "01000101010000000200000000000000"); + assert_eq!(codec.decode_transport_set_request(&set_bytes, 1), Ok(set)); + let mut short = [0xaa; 15]; assert_eq!( codec.encode_transport_set_request(set, &mut short), - Err(EncodeError::OutputTooSmall { required: 32 }) + Err(EncodeError::OutputTooSmall { required: 16 }) ); assert!(short.iter().all(|byte| *byte == 0xaa)); for end in 0..set_bytes.len() { assert!( codec - .decode_transport_set_request(&set_bytes[..end], 2) + .decode_transport_set_request(&set_bytes[..end], 1) .is_err() ); } let mut bad = set_bytes; bad[8] = 3; - assert!(codec.decode_transport_set_request(&bad, 2).is_err()); + assert!(codec.decode_transport_set_request(&bad, 1).is_err()); bad = set_bytes; bad[3] = 0; - assert!(codec.decode_transport_set_request(&bad, 2).is_err()); + assert!(codec.decode_transport_set_request(&bad, 1).is_err()); let mut extension = set_bytes.to_vec(); extension.extend_from_slice(&[3, 0, WIRE_U8, 0, 1, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0]); assert_eq!( - codec.decode_transport_set_request(&extension, 3), + codec.decode_transport_set_request(&extension, 2), Ok(set), "unknown optional fields are skippable" ); - extension[32 + 3] = 1; + extension[16 + 3] = 1; assert_eq!( - codec.decode_transport_set_request(&extension, 3), + codec.decode_transport_set_request(&extension, 2), Err(DecodeError::UnknownRequiredField) ); + // The retired position (#1635): field 2 of the set, field 2 of the snapshot and field 3 of the + // event are refused in either flag form, and an unknown optional field in the same slot is + // skipped only when it is not retired. + let retired = |base: &[u8], id: u8, mandatory: bool| { + let mut bytes = base.to_vec(); + bytes.extend_from_slice(&[id, 0, 4, u8::from(mandatory), 8, 0, 0, 0]); + bytes.extend_from_slice(&9_u64.to_le_bytes()); + bytes + }; + for mandatory in [false, true] { + let expected = if mandatory { + DecodeError::UnknownRequiredField + } else { + DecodeError::InvalidTlv + }; + assert_eq!( + codec.decode_transport_set_request(&retired(&set_bytes, 2, mandatory), 2), + Err(expected) + ); + } + let snapshot = TransportSnapshot { state: TransportState::Stopped, - position: crate::SampleTime(2), effective_sample: crate::SampleTime(3), }; - let mut snapshot_bytes = [0_u8; 48]; + let mut snapshot_bytes = [0_u8; 32]; codec .encode_transport_snapshot(snapshot, &mut snapshot_bytes) .expect("snapshot encode"); @@ -1978,30 +1992,42 @@ fn b3a_transport_goldens_truncation_and_direct_codec_are_strict() { hex(&snapshot_bytes), concat!( "01000101010000000100000000000000", - "02000401080000000200000000000000", "03000401080000000300000000000000" ) ); assert_eq!( - codec.decode_transport_snapshot(&snapshot_bytes, 3), + codec.decode_transport_snapshot(&snapshot_bytes, 2), Ok(snapshot) ); for end in 0..snapshot_bytes.len() { assert!( codec - .decode_transport_snapshot(&snapshot_bytes[..end], 3) + .decode_transport_snapshot(&snapshot_bytes[..end], 2) .is_err() ); } + // The position sits between the two fields, where it was: field order is by ID. + let mut with_position = snapshot_bytes[..16].to_vec(); + with_position.extend_from_slice(&retired(&[], 2, false)); + with_position.extend_from_slice(&snapshot_bytes[16..]); + assert_eq!( + codec.decode_transport_snapshot(&with_position, 3), + Err(DecodeError::InvalidTlv) + ); + with_position[16 + 3] = 1; + assert_eq!( + codec.decode_transport_snapshot(&with_position, 3), + Err(DecodeError::UnknownRequiredField) + ); let event = TransportStateEvent { event_sequence: 1, state: TransportState::Playing, - position: crate::SampleTime(2), effective_sample: crate::SampleTime(3), origin_request_id: crate::RequestId::new(9), }; - let mut event_bytes = [0_u8; 80]; + let mut event_bytes = [0_u8; 64]; + assert_eq!(codec.encoded_transport_state_event_len(event), 64); codec .encode_transport_state_event(event, &mut event_bytes) .expect("event encode"); @@ -2010,32 +2036,44 @@ fn b3a_transport_goldens_truncation_and_direct_codec_are_strict() { concat!( "01000401080000000100000000000000", "02000101010000000200000000000000", - "03000401080000000200000000000000", "04000401080000000300000000000000", "05000400080000000900000000000000" ) ); assert_eq!( - codec.decode_transport_state_event(&event_bytes, 5), + codec.decode_transport_state_event(&event_bytes, 4), Ok(event) ); for end in 0..event_bytes.len() { assert!( codec - .decode_transport_state_event(&event_bytes[..end], 5) + .decode_transport_state_event(&event_bytes[..end], 4) .is_err() ); } + let mut with_position = event_bytes[..32].to_vec(); + with_position.extend_from_slice(&retired(&[], 3, false)); + with_position.extend_from_slice(&event_bytes[32..]); + assert_eq!( + codec.decode_transport_state_event(&with_position, 5), + Err(DecodeError::InvalidTlv) + ); + with_position[32 + 3] = 1; + assert_eq!( + codec.decode_transport_state_event(&with_position, 5), + Err(DecodeError::UnknownRequiredField) + ); let no_origin = TransportStateEvent { origin_request_id: None, ..event }; - let mut no_origin_bytes = [0_u8; 64]; + let mut no_origin_bytes = [0_u8; 48]; + assert_eq!(codec.encoded_transport_state_event_len(no_origin), 48); codec .encode_transport_state_event(no_origin, &mut no_origin_bytes) .expect("no origin encode"); assert_eq!( - codec.decode_transport_state_event(&no_origin_bytes, 4), + codec.decode_transport_state_event(&no_origin_bytes, 3), Ok(no_origin) ); } diff --git a/crates/protocol/src/model.rs b/crates/protocol/src/model.rs index eaeb1f78a..5d684b522 100644 --- a/crates/protocol/src/model.rs +++ b/crates/protocol/src/model.rs @@ -346,13 +346,19 @@ pub enum SessionEdit { /// This edit's own ramp length in samples; absent means the row's default. A row whose /// ramp is fixed refuses an explicit length. Transaction metadata, never model state. ramp_samples: Option, + /// The native effect this edit was written for (#1678); only in an `inserts` or `console` + /// section. A row's numeric ID depends on its effect, and a rebuild can change the effect + /// an insert or console slot runs, so the core refuses the edit with + /// [`controls::ControlError::EffectChanged`], before anything is written, when the + /// instance it names runs another. Absent applies the edit to whatever runs there now. + expected_effect: Option, }, } impl SessionEdit { /// A [`SessionEdit::SetControl`] of `address` to `value`: the one way to set a value /// (decision 17, #1561). `ramp_samples` is the edit's own ramp length, or `None` for the - /// row's default. + /// row's default. It names no expected effect; see [`Self::with_expected_effect`]. #[must_use] pub const fn set_control( address: controls::ControlAddress, @@ -363,9 +369,23 @@ impl SessionEdit { address, value, ramp_samples, + expected_effect: None, } } + /// This edit with the native effect it was written for (#1678). Only a + /// [`SessionEdit::SetControl`] carries one; any other edit is returned unchanged. + #[must_use] + pub fn with_expected_effect(mut self, effect: StableId) -> Self { + if let Self::SetControl { + expected_effect, .. + } = &mut self + { + *expected_effect = Some(effect); + } + self + } + /// Return the stable numeric opcode for this typed edit. #[must_use] pub const fn opcode(&self) -> SessionEditOpcode { @@ -875,7 +895,12 @@ pub fn apply_session_edit( address, value, ramp_samples, + expected_effect, } => { + if let Some(expected) = expected_effect { + controls::check_expected_effect(session, address, expected) + .map_err(SessionEditError::Control)?; + } let sample_rate_hz = session.sample_rate_hz; controls::set(session, address, *value, *ramp_samples, sample_rate_hz) .map_err(SessionEditError::Control)?; @@ -1288,8 +1313,14 @@ impl SessionStore { address, value, ramp_samples, + expected_effect, } = edit { + // A monitor row runs no effect: an expected effect on it is refused (#1678). + if let Some(expected) = expected_effect { + controls::check_expected_effect(&candidate, address, expected) + .map_err(|error| refused(SessionEditError::Control(error)))?; + } let rate = candidate.sample_rate_hz; // The monitor sort of #1559 D3: a monitor row is checked here and written to // the overlay by the host; a model row goes through `set`. @@ -1405,10 +1436,16 @@ impl SessionStore { address, value, ramp_samples, + expected_effect, } = edit else { return None; }; + // An edit written for another effect than the one that runs is the full path's to + // refuse, with its typed code (#1678). + if let Some(expected) = expected_effect { + controls::check_expected_effect(committed, address, expected).ok()?; + } // The monitor sort of #1559 D3, as the full path makes it. if let Some(write) = controls::check_monitor(committed, address, *value, *ramp_samples, rate).ok()? diff --git a/crates/protocol/src/queue.rs b/crates/protocol/src/queue.rs index 685a98033..c2dfd3991 100644 --- a/crates/protocol/src/queue.rs +++ b/crates/protocol/src/queue.rs @@ -63,8 +63,6 @@ pub enum ReliablePayload { event_sequence: u64, /// Effective absolute stopped/playing state code. state: crate::TransportState, - /// Effective absolute transport position. - position: SampleTime, /// Effective engine sample. effective_sample: SampleTime, /// Originating transport-set command, when any. @@ -139,7 +137,6 @@ impl ReliableSlot { revision: SessionRevision, event_sequence: u64, state: crate::TransportState, - position: SampleTime, effective_sample: SampleTime, origin_request_id: Option, ) -> Self { @@ -150,7 +147,6 @@ impl ReliableSlot { payload: ReliablePayload::TransportState { event_sequence, state, - position, effective_sample, origin_request_id, }, diff --git a/crates/protocol/src/schema.rs b/crates/protocol/src/schema.rs index 40a8ebd21..548fe7ec9 100644 --- a/crates/protocol/src/schema.rs +++ b/crates/protocol/src/schema.rs @@ -355,6 +355,34 @@ pub(crate) mod control_address { }; } +/// The address message of a `SET_CONTROL` edit (#1678): the control list's address fields, and a +/// seventh, the native effect the edit was written for. The list's own rows and an automation target +/// use [`control_address`], which has no seventh field. +pub(crate) mod set_control_address { + use super::*; + pub(crate) const OWNER_KIND: FieldSpec = control_address::OWNER_KIND; + pub(crate) const OWNER_ID: FieldSpec = control_address::OWNER_ID; + pub(crate) const SECTION: FieldSpec = control_address::SECTION; + pub(crate) const SLOT_ID: FieldSpec = control_address::SLOT_ID; + pub(crate) const CONTROL: FieldSpec = control_address::CONTROL; + pub(crate) const LANE: FieldSpec = control_address::LANE; + /// The native effect ID the edit was written for; present only in an `inserts` or `console` + /// section. The core refuses the edit when that insert or slot runs another effect. + pub(crate) const EXPECTED_EFFECT: FieldSpec = FieldSpec::opt(7, Wire::Utf8); + pub(crate) static SPEC: MessageSpec = MessageSpec { + name: "SetControlAddress", + fields: &[ + OWNER_KIND, + OWNER_ID, + SECTION, + SLOT_ID, + CONTROL, + LANE, + EXPECTED_EFFECT, + ], + }; +} + pub(crate) mod control_entry { use super::{FieldSpec, MessageSpec, Wire, control_address, enum_choice}; @@ -591,21 +619,30 @@ pub(crate) mod transport_get { pub(crate) mod transport_set { use super::*; pub(crate) const STATE: FieldSpec = FieldSpec::req(1, Wire::U8); - pub(crate) const POSITION: FieldSpec = FieldSpec::opt(2, Wire::U64); pub(crate) static SPEC: MessageSpec = MessageSpec { name: "TransportSet", - fields: &[STATE, POSITION], + fields: &[STATE], + }; + /// The retired position (#1635): field 2 is refused in either flag form, never skipped as an + /// unknown optional field and never reallocated. + pub(crate) static RETIRED: MessageSpec = MessageSpec { + name: "TransportSetRetiredFields", + fields: &[FieldSpec::opt(2, Wire::U64)], }; } pub(crate) mod transport_snapshot { use super::*; pub(crate) const STATE: FieldSpec = FieldSpec::req(1, Wire::U8); - pub(crate) const POSITION: FieldSpec = FieldSpec::req(2, Wire::U64); pub(crate) const EFFECTIVE_SAMPLE: FieldSpec = FieldSpec::req(3, Wire::U64); pub(crate) static SPEC: MessageSpec = MessageSpec { name: "TransportSnapshot", - fields: &[STATE, POSITION, EFFECTIVE_SAMPLE], + fields: &[STATE, EFFECTIVE_SAMPLE], + }; + /// The retired position (#1635): field 2. + pub(crate) static RETIRED: MessageSpec = MessageSpec { + name: "TransportSnapshotRetiredFields", + fields: &[FieldSpec::opt(2, Wire::U64)], }; } @@ -613,18 +650,16 @@ pub(crate) mod transport_state_event { use super::*; pub(crate) const EVENT_SEQUENCE: FieldSpec = FieldSpec::req(1, Wire::U64); pub(crate) const STATE: FieldSpec = FieldSpec::req(2, Wire::U8); - pub(crate) const POSITION: FieldSpec = FieldSpec::req(3, Wire::U64); pub(crate) const EFFECTIVE_SAMPLE: FieldSpec = FieldSpec::req(4, Wire::U64); pub(crate) const ORIGIN_REQUEST_ID: FieldSpec = FieldSpec::opt(5, Wire::U64); pub(crate) static SPEC: MessageSpec = MessageSpec { name: "TransportStateEvent", - fields: &[ - EVENT_SEQUENCE, - STATE, - POSITION, - EFFECTIVE_SAMPLE, - ORIGIN_REQUEST_ID, - ], + fields: &[EVENT_SEQUENCE, STATE, EFFECTIVE_SAMPLE, ORIGIN_REQUEST_ID], + }; + /// The retired position (#1635): field 3. + pub(crate) static RETIRED: MessageSpec = MessageSpec { + name: "TransportStateEventRetiredFields", + fields: &[FieldSpec::opt(3, Wire::U64)], }; } @@ -1593,11 +1628,12 @@ pub(crate) mod session { } /// `SET_CONTROL` (decision 17, C5): any control by its stable address (#1553 D2). The address is - /// the control address message of the control list; a set requires its `control` and `lane`. + /// the control address message of the control list, with an optional seventh field, the native + /// effect the edit was written for (#1678); a set requires its `control` and `lane`. pub(crate) mod set_control { use super::*; pub(crate) const ADDRESS: FieldSpec = - FieldSpec::msg(1, true, false, &crate::schema::control_address::SPEC); + FieldSpec::msg(1, true, false, &crate::schema::set_control_address::SPEC); pub(crate) const VALUE: FieldSpec = FieldSpec::req(2, Wire::F32); /// This edit's own ramp length in samples, absent for the row's default. pub(crate) const RAMP_SAMPLES: FieldSpec = FieldSpec::opt(3, Wire::U32); @@ -1789,6 +1825,7 @@ mod tests { &session_committed::SPEC, &control_list_request::SPEC, &control_address::SPEC, + &set_control_address::SPEC, &control_entry::SPEC, &control_list_page::SPEC, &control_state_page::SPEC, @@ -1798,8 +1835,11 @@ mod tests { &response_result::SPEC, &transport_get::SPEC, &transport_set::SPEC, + &transport_set::RETIRED, &transport_snapshot::SPEC, + &transport_snapshot::RETIRED, &transport_state_event::SPEC, + &transport_state_event::RETIRED, &observation_tap_key::SPEC, &observation_ended::SPEC, &meter_batch::SPEC, @@ -1945,8 +1985,14 @@ mod tests { ); assert_eq!(transport_set::SPEC.field_count(&[]), Ok(1)); assert_eq!( - transport_set::SPEC.field_count(&[(transport_set::POSITION, 1)]), - Ok(2) + transport_state_event::SPEC.field_count(&[]), + Ok(3), + "the event's origin is optional, so three required fields count" + ); + assert_eq!( + transport_state_event::SPEC + .field_count(&[(transport_state_event::ORIGIN_REQUEST_ID, 1)]), + Ok(4) ); assert_eq!( counter_snapshot::SPEC.field_count(&[(counter_snapshot::VALUE, 3)]), @@ -1957,8 +2003,10 @@ mod tests { Err(crate::EncodeError::LimitExceeded) ); assert_eq!( - transport_set::SPEC - .field_count(&[(transport_set::POSITION, 1), (transport_set::POSITION, 1),]), + transport_state_event::SPEC.field_count(&[ + (transport_state_event::ORIGIN_REQUEST_ID, 1), + (transport_state_event::ORIGIN_REQUEST_ID, 1), + ]), Err(crate::EncodeError::LimitExceeded) ); } diff --git a/crates/protocol/src/session_wire.rs b/crates/protocol/src/session_wire.rs index c61e512b7..a51d2ad76 100644 --- a/crates/protocol/src/session_wire.rs +++ b/crates/protocol/src/session_wire.rs @@ -539,8 +539,15 @@ fn tx_edit_payload(sink: &mut dyn Sink, edit: &SessionEdit) -> Result<(), Encode } SessionEdit::UpsertVca { vca } => tx_message(sink, fields[0], |v| tx_vca(v, vca)), SessionEdit::RemoveVca { vca_id } => tx_id(sink, fields[0], vca_id), - SessionEdit::SetControl { address, value, .. } => { - tx_message(sink, fields[0], |v| tx_control_address(v, address))?; + SessionEdit::SetControl { + address, + value, + expected_effect, + .. + } => { + tx_message(sink, fields[0], |v| { + tx_control_address(v, address, expected_effect.as_ref()) + })?; tx_f32(sink, fields[1], *value) } }?; @@ -1008,8 +1015,9 @@ fn tx_route(sink: &mut dyn Sink, value: &Route) -> Result<(), EncodeError> { value.follows_mute, ) } -/// A stored automation target is the control address `SET_CONTROL` carries (#1566 D4), written by -/// the same encoder; no session row is automatable, so a session owner has no spelling here. +/// A stored automation target is the control list's address message with `control` and `lane` +/// present (#1566 D4), and no expected effect; no session row is automatable, so a session owner +/// has no spelling here. fn tx_automation_target( sink: &mut dyn Sink, value: &controls::ControlAddress, @@ -1017,7 +1025,7 @@ fn tx_automation_target( if value.owner == controls::Owner::Session { return Err(EncodeError::InvalidField); } - tx_control_address(sink, value) + crate::message_wire::write_automation_target_address(sink, value) } fn tx_automation_segment( sink: &mut dyn Sink, @@ -1262,26 +1270,35 @@ fn parse_edit(message: Message<'_>) -> Result { crate::SessionEditOpcode::RemoveVca => Ok(SessionEdit::RemoveVca { vca_id: stable_id(one_spec!(payload, fields[0])?)?, }), - crate::SessionEditOpcode::SetControl => Ok(SessionEdit::SetControl { - address: parse_control_address(payload.nested_value(one_spec!(payload, fields[0])?)?)?, - // Not required to be finite here: the control validator refuses it, naming the edit. - value: read_f32(one_spec!(payload, fields[1])?)?, - ramp_samples, - }), + crate::SessionEditOpcode::SetControl => { + let (address, expected_effect) = + parse_control_address(payload.nested_value(one_spec!(payload, fields[0])?)?)?; + Ok(SessionEdit::SetControl { + address, + // Not required to be finite here: the control validator refuses it, naming the edit. + value: read_f32(one_spec!(payload, fields[1])?)?, + ramp_samples, + expected_effect, + }) + } } } -/// The control address of a `SetControl` edit: the control list's address message, in which a set -/// requires the `control` and the `lane`. Its encoder and decoder are the list's (`message_wire`). +/// The control address of a `SetControl` edit: the control list's address fields, in which a set +/// requires the `control` and the `lane`, and the optional expected effect (#1678) as a seventh +/// field. Its encoder and decoder are `message_wire`'s set-control edit address codec. fn tx_control_address( sink: &mut dyn Sink, address: &controls::ControlAddress, + expected_effect: Option<&StableId>, ) -> Result<(), EncodeError> { - crate::message_wire::write_set_control_address(sink, address) + crate::message_wire::write_set_control_edit_address(sink, address, expected_effect) } -fn parse_control_address(message: Message<'_>) -> Result { - crate::message_wire::decode_set_control_address(message) +fn parse_control_address( + message: Message<'_>, +) -> Result<(controls::ControlAddress, Option), DecodeError> { + crate::message_wire::decode_set_control_edit_address(message) } fn parse_render_profile(message: Message<'_>) -> Result { @@ -1667,7 +1684,7 @@ fn parse_route(message: Message<'_>) -> Result { }) } fn parse_automation_target(message: Message<'_>) -> Result { - let address = parse_control_address(message)?; + let address = crate::message_wire::decode_automation_target_address(message)?; if address.owner == controls::Owner::Session { return Err(DecodeError::InvalidField); } diff --git a/crates/protocol/src/session_wire/tests.rs b/crates/protocol/src/session_wire/tests.rs index ca4a2a1a6..d6174210e 100644 --- a/crates/protocol/src/session_wire/tests.rs +++ b/crates/protocol/src/session_wire/tests.rs @@ -1789,6 +1789,7 @@ fn control_edit( ramp_samples: Option, ) -> SessionEdit { SessionEdit::SetControl { + expected_effect: None, address: controls::ControlAddress { owner, section, @@ -1935,13 +1936,137 @@ fn set_control_refuses_an_address_that_breaks_its_rules() { } } -/// #1566 D4: a stored automation target is the `SET_CONTROL` address message, minus the session -/// owner. No session row is automatable, so a session address has no spelling as a target: the -/// decoder refuses it and the encoder refuses to write one, while the same address is valid for a -/// `SET_CONTROL` write. +/// #1678: the expected effect of a `SET_CONTROL` edit is the seventh field of its address message, +/// accepted only in an `inserts` or `console` section and only as a stable ID; the encoder refuses +/// the same shapes. (A stored automation target refuses the field in every section: +/// `an_automation_target_carries_no_expected_effect`.) /// -/// Red if the target codec accepts what the control list accepts (a session-owned row) or stops -/// being the one address codec. +/// Red if an expected effect on a builtin row is skipped at decode (the edit would be acknowledged +/// with a check that never ran), if a malformed ID reaches the core, or if the encoder writes a +/// field the decoder refuses. +#[test] +fn set_control_expected_effect_is_held_to_its_section() { + use schema::set_control_address as a; + let field = + |spec: FieldSpec, value: &[u8]| (spec.id, spec.wire.raw(), spec.mandatory, value.to_vec()); + let parse = |section: u8, slot: Option<&str>, expected: Option<&str>| { + let mut fields = vec![ + field(a::OWNER_KIND, &[2]), + field(a::OWNER_ID, b"vocal"), + field(a::SECTION, &[section]), + ]; + if let Some(slot) = slot { + fields.push(field(a::SLOT_ID, slot.as_bytes())); + } + fields.push(field(a::CONTROL, &5_u32.to_le_bytes())); + fields.push(field(a::LANE, &[1])); + if let Some(expected) = expected { + fields.push(field(a::EXPECTED_EFFECT, expected.as_bytes())); + } + parse_control_address(Message::nested(&raw_message(fields)).expect("nested")) + }; + for section in [2, 5] { + let (address, expected) = parse(section, Some("eq"), Some("miso.compressor")) + .expect("an effect section carries an expected effect"); + assert_eq!(expected, Some(id("miso.compressor"))); + assert!(matches!( + address.section, + controls::Section::Inserts(_) | controls::Section::Console(_) + )); + assert_eq!(parse(section, Some("eq"), None).map(|(_, e)| e), Ok(None)); + assert_eq!( + parse(section, Some("eq"), Some("Not A Stable Id")), + Err(DecodeError::InvalidField) + ); + assert_eq!( + parse(section, Some("eq"), Some("")), + Err(DecodeError::InvalidField) + ); + } + assert!(parse(4, None, None).is_ok() && parse(6, None, None).is_ok()); + assert_eq!( + parse(4, None, Some("miso.compressor")), + Err(DecodeError::InvalidField), + "builtins run no effect" + ); + assert_eq!( + parse(6, None, Some("miso.compressor")), + Err(DecodeError::InvalidField), + "a strip's own rows run no effect" + ); + // The encoder refuses what the decoder refuses. + let builtins = controls::ControlAddress { + owner: controls::Owner::Strip(id("vocal")), + section: controls::Section::Builtins, + control: 5, + lane: ParameterChannel::Left, + }; + let edits = [SessionEdit::set_control(builtins, 0.0, None).with_expected_effect(id("a"))]; + let transaction = SessionTransactionFrame { + request_id: RequestId::new(1).expect("request ID"), + expected_revision: ExpectedRevision::Exact(crate::SessionRevision(0)), + edits: &edits, + }; + assert_eq!( + ProtocolCodec::default().encoded_session_transaction_len(&transaction), + Err(EncodeError::InvalidField) + ); +} + +/// #1566 D4: a stored automation target is the control list's address message with `control` and +/// `lane` present, minus the session owner. No session row is automatable, so a session address has +/// no spelling as a target: the decoder refuses it and the encoder refuses to write one, while the +/// same address is valid for a `SET_CONTROL` write. +/// +/// Red if the target codec accepts what the control list accepts (a session-owned row). +/// A stored automation target holds no expected effect: the `SET_CONTROL` address's seventh field is +/// refused in a target in every section, as `SET_CONTROL` refuses it outside an effect section +/// (`InvalidField` in the optional flag form, `InvalidTlv` in the mandatory one). +/// +/// Red if the target decoder skips the field as an unknown optional field, so a target written +/// with an effect check that never runs would be stored as if it had none. +#[test] +fn an_automation_target_carries_no_expected_effect() { + use schema::set_control_address as a; + let field = + |spec: FieldSpec, value: &[u8]| (spec.id, spec.wire.raw(), spec.mandatory, value.to_vec()); + let target = |section: u8, slot: Option<&str>, expected: Option| { + let mut fields = vec![ + field(a::OWNER_KIND, &[2]), + field(a::OWNER_ID, b"vocal"), + field(a::SECTION, &[section]), + ]; + if let Some(slot) = slot { + fields.push(field(a::SLOT_ID, slot.as_bytes())); + } + fields.push(field(a::CONTROL, &5_u32.to_le_bytes())); + fields.push(field(a::LANE, &[1])); + if let Some(mandatory) = expected { + fields.push(( + a::EXPECTED_EFFECT.id, + a::EXPECTED_EFFECT.wire.raw(), + mandatory, + b"miso.compressor".to_vec(), + )); + } + let bytes = raw_message(fields); + parse_automation_target(Message::nested(&bytes).expect("nested")) + }; + for (section, slot) in [(2, Some("eq")), (5, Some("eq")), (4, None), (6, None)] { + assert!(target(section, slot, None).is_ok(), "section {section}"); + assert_eq!( + target(section, slot, Some(false)), + Err(DecodeError::InvalidField), + "section {section}, optional form" + ); + assert_eq!( + target(section, slot, Some(true)), + Err(DecodeError::InvalidTlv), + "section {section}, mandatory form" + ); + } +} + #[test] fn an_automation_target_cannot_be_owned_by_the_session() { use schema::control_address as a; diff --git a/crates/protocol/src/typed_frame.rs b/crates/protocol/src/typed_frame.rs index 8b66a4b46..081befa3c 100644 --- a/crates/protocol/src/typed_frame.rs +++ b/crates/protocol/src/typed_frame.rs @@ -856,30 +856,22 @@ mod tests { #[test] fn complete_frame_header_uses_sizing_sink_optional_and_repeated_counts() { let codec = ProtocolCodec::default(); - let without_position = encode_command( - &codec, - &TypedCommandFrame { - request_id: request_id(), - expected_revision: crate::ExpectedRevision::Exact(SessionRevision(7)), - payload: CommandPayload::TransportSet(TransportSetRequest { - state: crate::TransportState::Playing, - position: None, - }), - }, - ); - let with_position = encode_command( - &codec, - &TypedCommandFrame { - request_id: request_id(), - expected_revision: crate::ExpectedRevision::Exact(SessionRevision(7)), - payload: CommandPayload::TransportSet(TransportSetRequest { - state: crate::TransportState::Playing, - position: Some(crate::SampleTime(9)), - }), - }, - ); - assert_eq!(outer_field_count(&without_position), 1); - assert_eq!(outer_field_count(&with_position), 2); + let event = |origin_request_id| { + encode_event( + &codec, + &TypedEventFrame { + revision: SessionRevision(7), + payload: EventPayload::TransportState(TransportStateEvent { + event_sequence: 1, + state: crate::TransportState::Playing, + effective_sample: crate::SampleTime(9), + origin_request_id, + }), + }, + ) + }; + assert_eq!(outer_field_count(&event(None)), 3); + assert_eq!(outer_field_count(&event(Some(request_id()))), 4); let diagnostic = crate::Diagnostic { code: "protocol.count".to_owned(), @@ -996,7 +988,6 @@ mod tests { expected_revision: crate::ExpectedRevision::Exact(SessionRevision(7)), payload: CommandPayload::TransportSet(TransportSetRequest { state: crate::TransportState::Playing, - position: Some(crate::SampleTime(9)), }), }, MessageId::TransportSet, @@ -1062,7 +1053,6 @@ mod tests { expected_revision: crate::ExpectedRevision::Any, payload: CommandPayload::TransportSet(TransportSetRequest { state: crate::TransportState::Stopped, - position: None, }), }; assert_eq!( @@ -1115,7 +1105,6 @@ mod tests { revision: SessionRevision(7), payload: SuccessResponsePayload::TransportGetSnapshot(TransportSnapshot { state: crate::TransportState::Stopped, - position: crate::SampleTime(0), effective_sample: crate::SampleTime(0), }), }, @@ -1127,7 +1116,6 @@ mod tests { revision: SessionRevision(7), payload: SuccessResponsePayload::TransportSetSnapshot(TransportSnapshot { state: crate::TransportState::Playing, - position: crate::SampleTime(9), effective_sample: crate::SampleTime(9), }), }, @@ -1346,7 +1334,6 @@ mod tests { payload: EventPayload::TransportState(TransportStateEvent { event_sequence: 1, state: crate::TransportState::Stopped, - position: crate::SampleTime(0), effective_sample: crate::SampleTime(0), origin_request_id: None, }), diff --git a/crates/protocol/tests/expected_effect.rs b/crates/protocol/tests/expected_effect.rs new file mode 100644 index 000000000..82911f2c7 --- /dev/null +++ b/crates/protocol/tests/expected_effect.rs @@ -0,0 +1,257 @@ +//! Issue #1678: a `SET_CONTROL` edit of an effect row may name the native effect it was written +//! for, and the core refuses it with `control.effect.changed` when the insert or console slot now +//! runs another effect (a rebuild replaced it), before anything is written. The refusal is the +//! same on the full path and on the path that prepares a transaction of values in place. +//! +//! The base is the canonical fixture (track `vocal` with an insert `eq`) plus a console slot +//! `comp` that runs `miso.compressor`. + +use protocol::control_core::{ControlAddress, ControlError, Lane, Owner, Section}; +use protocol::{ExpectedRevision, SessionEdit, SessionEditError, SessionStore, SessionStoreError}; +use session::{ + CompileCaps, ConsoleEntry, ConsoleSlot, EffectIdentity, EffectQuality, LinkMode, RackName, + StableId, parse_session_json, +}; + +const FIXTURE: &str = include_str!("../../../fixtures/session/v1/canonical.json"); + +fn id(value: &str) -> StableId { + StableId::parse(value).expect("literal stable ID") +} + +fn store() -> SessionStore { + let mut model = parse_session_json(FIXTURE).expect("fixture"); + model.console.pre_insert.push(ConsoleSlot { + slot: id("comp"), + identity: EffectIdentity::Native { + effect_id: id("miso.compressor"), + }, + quality: EffectQuality::Normal, + link_mode: LinkMode::Maximum, + }); + for track in &mut model.tracks { + track.console.push(ConsoleEntry { + slot: id("comp"), + bypass: false, + params: Vec::new(), + }); + } + // The fixture's automation targets the `eq`, which the identity swaps below replace. + model.automation.clear(); + let caps = 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, + }; + SessionStore::new(model, caps).expect("the base compiles") +} + +fn insert_bypass() -> ControlAddress { + ControlAddress { + owner: Owner::Strip(id("vocal")), + section: Section::Inserts(id("eq")), + control: 0, + lane: Lane::Both, + } +} + +fn console_bypass() -> ControlAddress { + ControlAddress { + owner: Owner::Strip(id("vocal")), + section: Section::Console(id("comp")), + control: 0, + lane: Lane::Both, + } +} + +/// The insert `eq` of `vocal` now runs `effect`. +fn run(effect: &str) -> SessionEdit { + SessionEdit::SetEffectIdentity { + track_id: id("vocal"), + rack_name: RackName::Inserts, + effect_id: id("eq"), + identity: EffectIdentity::Native { + effect_id: id(effect), + }, + } +} + +fn changed(operation_index: usize) -> SessionStoreError { + SessionStoreError::Edit { + operation_index, + error: SessionEditError::Control(ControlError::EffectChanged), + } +} + +/// Gate 1 at the store, on both preparation paths: after a rebuild that changed an insert's effect, +/// an edit written for the old effect is refused with `control.effect.changed`, nothing changes, +/// and the same edit without the expected effect (or with the effect that runs) commits. +/// +/// Red if the check is missing on either path (the stale edit would land on the compressor's row +/// 0, which is its bypass, with no refusal), or if the matching and absent forms are refused. +#[test] +fn a_stale_effect_row_edit_is_refused_after_a_rebuild() { + let mut store = store(); + store + .apply_transaction( + ExpectedRevision::Exact(store.revision()), + &[run("miso.parametric-eq")], + ) + .expect("the insert runs a launch equalizer"); + let revision = store.revision(); + let before = store.canonical_snapshot().to_owned(); + let stale = SessionEdit::set_control(insert_bypass(), 1.0, None) + .with_expected_effect(id("miso.parametric-eq")); + // The insert still runs the equalizer: the edit commits on both paths. + for in_place in [false, true] { + let prepared = if in_place { + store.prepare_transaction_in_place( + ExpectedRevision::Exact(revision), + std::slice::from_ref(&stale), + ) + } else { + store.prepare_transaction( + ExpectedRevision::Exact(revision), + std::slice::from_ref(&stale), + ) + }; + assert!( + prepared.is_ok(), + "the matching edit commits (in place: {in_place})" + ); + } + // A rebuild replaces the equalizer with a compressor. + store + .apply_transaction(ExpectedRevision::Exact(revision), &[run("miso.compressor")]) + .expect("the insert runs a compressor"); + let revision = store.revision(); + let before_stale = store.canonical_snapshot().to_owned(); + assert_ne!(before, before_stale); + for in_place in [false, true] { + let prepared = if in_place { + store.prepare_transaction_in_place( + ExpectedRevision::Exact(revision), + std::slice::from_ref(&stale), + ) + } else { + store.prepare_transaction( + ExpectedRevision::Exact(revision), + std::slice::from_ref(&stale), + ) + }; + assert_eq!( + prepared.err().expect("the stale edit refuses"), + changed(0), + "in place: {in_place}" + ); + } + // The refusal names its own edit even when an earlier one was valid, and applies the same + // check inside a transaction that changes the effect itself. + let good = SessionEdit::set_control(console_bypass(), 1.0, None) + .with_expected_effect(id("miso.compressor")); + assert_eq!( + store + .prepare_transaction( + ExpectedRevision::Exact(revision), + &[good.clone(), stale.clone()] + ) + .err() + .expect("refuses"), + changed(1) + ); + assert_eq!( + store + .prepare_transaction( + ExpectedRevision::Exact(revision), + &[run("miso.parametric-eq"), stale.clone()] + ) + .map(|_| ()), + Ok(()), + "an edit is held to the effect the candidate runs at its place in the transaction" + ); + // Without an expected effect, or with the one that runs, the edit commits as it always did. + for edit in [ + SessionEdit::set_control(insert_bypass(), 1.0, None), + SessionEdit::set_control(insert_bypass(), 1.0, None) + .with_expected_effect(id("miso.compressor")), + ] { + for in_place in [false, true] { + let prepared = if in_place { + store.prepare_transaction_in_place( + ExpectedRevision::Exact(revision), + std::slice::from_ref(&edit), + ) + } else { + store.prepare_transaction( + ExpectedRevision::Exact(revision), + std::slice::from_ref(&edit), + ) + }; + assert!(prepared.is_ok(), "in place: {in_place}"); + } + } +} + +/// A console slot is held the same way, an unknown insert is still `control.address.unknown` +/// (the expected effect does not hide the address error), and an expected effect on a row that +/// runs no effect is refused rather than ignored. +/// +/// Red if the console section is not checked, if the unknown address reports the effect error, or +/// if the expectation on a builtin row is dropped without a word. +#[test] +fn the_expected_effect_follows_the_address() { + let store = store(); + let revision = store.revision(); + let refuse = |edit: SessionEdit| { + store + .prepare_transaction(ExpectedRevision::Exact(revision), &[edit]) + .err() + .expect("refuses") + }; + assert_eq!( + refuse( + SessionEdit::set_control(console_bypass(), 1.0, None) + .with_expected_effect(id("miso.gate-expander")) + ), + changed(0) + ); + assert!( + store + .prepare_transaction( + ExpectedRevision::Exact(revision), + &[SessionEdit::set_control(console_bypass(), 1.0, None) + .with_expected_effect(id("miso.compressor"))] + ) + .is_ok() + ); + let unknown = ControlAddress { + section: Section::Inserts(id("nope")), + ..insert_bypass() + }; + assert_eq!( + refuse( + SessionEdit::set_control(unknown, 1.0, None) + .with_expected_effect(id("miso.compressor")) + ), + SessionStoreError::Edit { + operation_index: 0, + error: SessionEditError::Control(ControlError::AddressUnknown), + } + ); + let builtin = ControlAddress { + owner: Owner::Strip(id("vocal")), + section: Section::Builtins, + control: 3, + lane: Lane::Both, + }; + assert_eq!( + refuse( + SessionEdit::set_control(builtin, 0.0, None) + .with_expected_effect(id("miso.compressor")) + ), + changed(0) + ); +} diff --git a/crates/protocol/tests/fixtures/set-control-frames.json b/crates/protocol/tests/fixtures/set-control-frames.json index a7d934e6c..6f1120944 100644 --- a/crates/protocol/tests/fixtures/set-control-frames.json +++ b/crates/protocol/tests/fixtures/set-control-frames.json @@ -6,7 +6,7 @@ "requestId": "1", "expectedRevision": "0", "edits": [ - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "c0c00000", "rampSamples": null } + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "c0c00000", "rampSamples": null, "expectedEffect": null } ], "hex": "4d49534f43544c00010000003000010003000000a000000001000000000000000000000000000000010000000000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c00000003000101010000000400000000000000050003000400000003000000000000000600010001000000010000000000000002000601040000000000c0c000000000" }, @@ -15,7 +15,7 @@ "requestId": "2", "expectedRevision": "1", "edits": [ - { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "c0400000", "rampSamples": 0 } + { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "c0400000", "rampSamples": 0, "expectedEffect": null } ], "hex": "4d49534f43544c00010000003000010003000000b000000002000000000000000100000000000000010000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000003000000000000000200090007000000746f2d6d61696e000300010101000000060000000000000005000300040000000100000000000000060001000100000003000000000000000200060104000000000040c00000000003000300040000000000000000000000" }, @@ -24,7 +24,7 @@ "requestId": "3", "expectedRevision": "2", "edits": [ - { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "3f800000", "rampSamples": 4294967295 } + { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "3f800000", "rampSamples": 4294967295, "expectedEffect": null } ], "hex": "4d49534f43544c00010000003000010003000000b000000003000000000000000200000000000000010000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000004000000000000000200090003000000677270000000000003000101010000000600000000000000050003000400000001000000000000000600010001000000030000000000000002000601040000000000803f000000000300030004000000ffffffff00000000" }, @@ -33,15 +33,15 @@ "requestId": "1099511627783", "expectedRevision": "1099511627785", "edits": [ - { "owner": { "kind": "strip", "id": "a" }, "section": { "kind": "console", "slot": "a" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "bb" }, "section": { "kind": "console", "slot": "bb" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "ccc" }, "section": { "kind": "console", "slot": "ccc" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "dddd" }, "section": { "kind": "console", "slot": "dddd" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "eeeee" }, "section": { "kind": "console", "slot": "eeeee" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "ffffff" }, "section": { "kind": "console", "slot": "ffffff" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "ggggggg" }, "section": { "kind": "console", "slot": "ggggggg" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "hhhhhhhh" }, "section": { "kind": "console", "slot": "hhhhhhhh" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "iiiiiiiii" }, "section": { "kind": "console", "slot": "iiiiiiiii" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null } + { "owner": { "kind": "strip", "id": "a" }, "section": { "kind": "console", "slot": "a" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "bb" }, "section": { "kind": "console", "slot": "bb" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "ccc" }, "section": { "kind": "console", "slot": "ccc" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "dddd" }, "section": { "kind": "console", "slot": "dddd" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "eeeee" }, "section": { "kind": "console", "slot": "eeeee" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "ffffff" }, "section": { "kind": "console", "slot": "ffffff" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "ggggggg" }, "section": { "kind": "console", "slot": "ggggggg" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "hhhhhhhh" }, "section": { "kind": "console", "slot": "hhhhhhhh" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "iiiiiiiii" }, "section": { "kind": "console", "slot": "iiiiiiiii" }, "control": 7, "lane": "both", "valueBits": "3e800000", "rampSamples": null, "expectedEffect": null } ], "hex": "4d49534f43544c000100000030000100030000004006000007000000000100000900000000010000090000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000020000000000000001000b0168000000060000000000000001000101010000000200000000000000020009000100000061000000000000000300010101000000050000000000000004000900010000006100000000000000050003000400000007000000000000000600010001000000030000000000000002000601040000000000803e0000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000020000000000000001000b0168000000060000000000000001000101010000000200000000000000020009000200000062620000000000000300010101000000050000000000000004000900020000006262000000000000050003000400000007000000000000000600010001000000030000000000000002000601040000000000803e0000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000020000000000000001000b0168000000060000000000000001000101010000000200000000000000020009000300000063636300000000000300010101000000050000000000000004000900030000006363630000000000050003000400000007000000000000000600010001000000030000000000000002000601040000000000803e0000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000020000000000000001000b0168000000060000000000000001000101010000000200000000000000020009000400000064646464000000000300010101000000050000000000000004000900040000006464646400000000050003000400000007000000000000000600010001000000030000000000000002000601040000000000803e0000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000020000000000000001000b0168000000060000000000000001000101010000000200000000000000020009000500000065656565650000000300010101000000050000000000000004000900050000006565656565000000050003000400000007000000000000000600010001000000030000000000000002000601040000000000803e0000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000020000000000000001000b0168000000060000000000000001000101010000000200000000000000020009000600000066666666666600000300010101000000050000000000000004000900060000006666666666660000050003000400000007000000000000000600010001000000030000000000000002000601040000000000803e0000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000020000000000000001000b0168000000060000000000000001000101010000000200000000000000020009000700000067676767676767000300010101000000050000000000000004000900070000006767676767676700050003000400000007000000000000000600010001000000030000000000000002000601040000000000803e0000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000020000000000000001000b0168000000060000000000000001000101010000000200000000000000020009000800000068686868686868680300010101000000050000000000000004000900080000006868686868686868050003000400000007000000000000000600010001000000030000000000000002000601040000000000803e0000000001000b01b800000002000000000000000100020102000000000800000000000002000b0198000000020000000000000001000b017800000006000000000000000100010101000000020000000000000002000900090000006969696969696969690000000000000003000101010000000500000000000000040009000900000069696969696969696900000000000000050003000400000007000000000000000600010001000000030000000000000002000601040000000000803e00000000" }, @@ -50,86 +50,98 @@ "requestId": "18446744073709551615", "expectedRevision": "18446744073709551614", "edits": [ - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "00000000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "80000000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "3dcccccd", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "c2a00000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "7f7fffff", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "00800000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "7f800000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "ff800000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "7fc00000", "rampSamples": null } + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "00000000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "80000000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "3dcccccd", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "c2a00000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "7f7fffff", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "00800000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "7f800000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "ff800000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "7fc00000", "rampSamples": null, "expectedEffect": null } ], "hex": "4d49534f43544c00010000003000010003000000a0050000fffffffffffffffffeffffffffffffff090000000000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000001000000000000000200060104000000000000000000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000001000000000000000200060104000000000000800000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000001000000000000000200060104000000cdcccc3d0000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c00000003000101010000000400000000000000050003000400000003000000000000000600010001000000010000000000000002000601040000000000a0c20000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000001000000000000000200060104000000ffff7f7f0000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000001000000000000000200060104000000000080000000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c00000003000101010000000400000000000000050003000400000003000000000000000600010001000000010000000000000002000601040000000000807f0000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000001000000000000000200060104000000000080ff0000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c00000003000101010000000400000000000000050003000400000003000000000000000600010001000000010000000000000002000601040000000000c07f00000000" }, + { + "name": "expected_effect", + "requestId": "101", + "expectedRevision": "13", + "edits": [ + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 4, "lane": "both", "valueBits": "c0000000", "rampSamples": null, "expectedEffect": "miso.parametric-eq" }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 4, "lane": "both", "valueBits": "c0000000", "rampSamples": null, "expectedEffect": "miso.compressor" }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 4, "lane": "both", "valueBits": "c0000000", "rampSamples": null, "expectedEffect": "miso.true-peak-limiter" }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 4, "lane": "both", "valueBits": "c0000000", "rampSamples": null, "expectedEffect": "miso.transient-shaper" } + ], + "hex": "4d49534f43544c000100000030000100030000003803000065000000000000000d00000000000000040000000000000001000b01c800000002000000000000000100020102000000000800000000000002000b01a8000000020000000000000001000b01880000000700000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000020000000000000004000900020000006571000000000000050003000400000004000000000000000600010001000000030000000000000007000900120000006d69736f2e706172616d65747269632d65710000000000000200060104000000000000c00000000001000b01c000000002000000000000000100020102000000000800000000000002000b01a0000000020000000000000001000b01800000000700000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000050000000000000004000900070000006465736b2d6571000500030004000000040000000000000006000100010000000300000000000000070009000f0000006d69736f2e636f6d70726573736f72000200060104000000000000c00000000001000b01c800000002000000000000000100020102000000000800000000000002000b01a8000000020000000000000001000b01880000000700000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000020000000000000004000900020000006571000000000000050003000400000004000000000000000600010001000000030000000000000007000900160000006d69736f2e747275652d7065616b2d6c696d6974657200000200060104000000000000c00000000001000b01c800000002000000000000000100020102000000000800000000000002000b01a8000000020000000000000001000b01880000000700000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000050000000000000004000900070000006465736b2d657100050003000400000004000000000000000600010001000000030000000000000007000900150000006d69736f2e7472616e7369656e742d7368617065720000000200060104000000000000c000000000" + }, { "name": "every_shape_lane_and_ramp", "requestId": "100", "expectedRevision": "12", "edits": [ - { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "bfc00000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "bf000000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "left", "valueBits": "3f000000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "left", "valueBits": "3fc00000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "left", "valueBits": "40200000", "rampSamples": null }, - { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "40600000", "rampSamples": null }, - { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "40900000", "rampSamples": null }, - { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "bfc00000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "right", "valueBits": "bf000000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "right", "valueBits": "3f000000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "right", "valueBits": "3fc00000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "right", "valueBits": "40200000", "rampSamples": null }, - { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "40600000", "rampSamples": null }, - { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "40900000", "rampSamples": null }, - { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "bfc00000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "both", "valueBits": "bf000000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "valueBits": "3f000000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "both", "valueBits": "3fc00000", "rampSamples": null }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "both", "valueBits": "40200000", "rampSamples": null }, - { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "40600000", "rampSamples": null }, - { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "40900000", "rampSamples": null }, - { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "bfc00000", "rampSamples": 0 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "bf000000", "rampSamples": 0 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "left", "valueBits": "3f000000", "rampSamples": 0 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "left", "valueBits": "3fc00000", "rampSamples": 0 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "left", "valueBits": "40200000", "rampSamples": 0 }, - { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "40600000", "rampSamples": 0 }, - { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "40900000", "rampSamples": 0 }, - { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "bfc00000", "rampSamples": 0 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "right", "valueBits": "bf000000", "rampSamples": 0 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "right", "valueBits": "3f000000", "rampSamples": 0 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "right", "valueBits": "3fc00000", "rampSamples": 0 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "right", "valueBits": "40200000", "rampSamples": 0 }, - { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "40600000", "rampSamples": 0 }, - { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "40900000", "rampSamples": 0 }, - { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "bfc00000", "rampSamples": 0 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "both", "valueBits": "bf000000", "rampSamples": 0 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "valueBits": "3f000000", "rampSamples": 0 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "both", "valueBits": "3fc00000", "rampSamples": 0 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "both", "valueBits": "40200000", "rampSamples": 0 }, - { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "40600000", "rampSamples": 0 }, - { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "40900000", "rampSamples": 0 }, - { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "bfc00000", "rampSamples": 480 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "bf000000", "rampSamples": 480 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "left", "valueBits": "3f000000", "rampSamples": 480 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "left", "valueBits": "3fc00000", "rampSamples": 480 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "left", "valueBits": "40200000", "rampSamples": 480 }, - { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "40600000", "rampSamples": 480 }, - { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "40900000", "rampSamples": 480 }, - { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "bfc00000", "rampSamples": 480 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "right", "valueBits": "bf000000", "rampSamples": 480 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "right", "valueBits": "3f000000", "rampSamples": 480 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "right", "valueBits": "3fc00000", "rampSamples": 480 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "right", "valueBits": "40200000", "rampSamples": 480 }, - { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "40600000", "rampSamples": 480 }, - { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "40900000", "rampSamples": 480 }, - { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "bfc00000", "rampSamples": 480 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "both", "valueBits": "bf000000", "rampSamples": 480 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "valueBits": "3f000000", "rampSamples": 480 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "both", "valueBits": "3fc00000", "rampSamples": 480 }, - { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "both", "valueBits": "40200000", "rampSamples": 480 }, - { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "40600000", "rampSamples": 480 }, - { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "40900000", "rampSamples": 480 } + { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "bfc00000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "bf000000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "left", "valueBits": "3f000000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "left", "valueBits": "3fc00000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "left", "valueBits": "40200000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "40600000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "40900000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "bfc00000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "right", "valueBits": "bf000000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "right", "valueBits": "3f000000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "right", "valueBits": "3fc00000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "right", "valueBits": "40200000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "40600000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "40900000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "bfc00000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "both", "valueBits": "bf000000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "valueBits": "3f000000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "both", "valueBits": "3fc00000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "both", "valueBits": "40200000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "40600000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "40900000", "rampSamples": null, "expectedEffect": null }, + { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "bfc00000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "bf000000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "left", "valueBits": "3f000000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "left", "valueBits": "3fc00000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "left", "valueBits": "40200000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "40600000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "40900000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "bfc00000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "right", "valueBits": "bf000000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "right", "valueBits": "3f000000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "right", "valueBits": "3fc00000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "right", "valueBits": "40200000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "40600000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "40900000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "bfc00000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "both", "valueBits": "bf000000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "valueBits": "3f000000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "both", "valueBits": "3fc00000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "both", "valueBits": "40200000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "40600000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "40900000", "rampSamples": 0, "expectedEffect": null }, + { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "bfc00000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "valueBits": "bf000000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "left", "valueBits": "3f000000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "left", "valueBits": "3fc00000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "left", "valueBits": "40200000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "40600000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "valueBits": "40900000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "bfc00000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "right", "valueBits": "bf000000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "right", "valueBits": "3f000000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "right", "valueBits": "3fc00000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "right", "valueBits": "40200000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "40600000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "valueBits": "40900000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "bfc00000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "builtins" }, "control": 3, "lane": "both", "valueBits": "bf000000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "valueBits": "3f000000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "both", "valueBits": "3fc00000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "strip", "id": "vocal" }, "section": { "kind": "console", "slot": "desk-eq" }, "control": 0, "lane": "both", "valueBits": "40200000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "route", "id": "to-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "40600000", "rampSamples": 480, "expectedEffect": null }, + { "owner": { "kind": "vca", "id": "grp" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "valueBits": "40900000", "rampSamples": 480, "expectedEffect": null } ], "hex": "4d49534f43544c00010000003000010003000000902a000064000000000000000c000000000000003f0000000000000001000b018800000002000000000000000100020102000000000800000000000002000b0168000000020000000000000001000b014800000004000000000000000100010101000000010000000000000003000101010000000600000000000000050003000400000001000000000000000600010001000000010000000000000002000601040000000000c0bf0000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000001000000000000000200060104000000000000bf0000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c00000003000101010000000600000000000000050003000400000002000000000000000600010001000000010000000000000002000601040000000000003f0000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000020000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000020000000000000004000900020000006571000000000000050003000400000001000000000000000600010001000000010000000000000002000601040000000000c03f0000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000020000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000050000000000000004000900070000006465736b2d65710005000300040000000000000000000000060001000100000001000000000000000200060104000000000020400000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000003000000000000000200090007000000746f2d6d61696e000300010101000000060000000000000005000300040000000100000000000000060001000100000001000000000000000200060104000000000060400000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b0158000000050000000000000001000101010000000400000000000000020009000300000067727000000000000300010101000000060000000000000005000300040000000100000000000000060001000100000001000000000000000200060104000000000090400000000001000b018800000002000000000000000100020102000000000800000000000002000b0168000000020000000000000001000b014800000004000000000000000100010101000000010000000000000003000101010000000600000000000000050003000400000001000000000000000600010001000000020000000000000002000601040000000000c0bf0000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000002000000000000000200060104000000000000bf0000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c00000003000101010000000600000000000000050003000400000002000000000000000600010001000000020000000000000002000601040000000000003f0000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000020000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000020000000000000004000900020000006571000000000000050003000400000001000000000000000600010001000000020000000000000002000601040000000000c03f0000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000020000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000050000000000000004000900070000006465736b2d65710005000300040000000000000000000000060001000100000002000000000000000200060104000000000020400000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000003000000000000000200090007000000746f2d6d61696e000300010101000000060000000000000005000300040000000100000000000000060001000100000002000000000000000200060104000000000060400000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b0158000000050000000000000001000101010000000400000000000000020009000300000067727000000000000300010101000000060000000000000005000300040000000100000000000000060001000100000002000000000000000200060104000000000090400000000001000b018800000002000000000000000100020102000000000800000000000002000b0168000000020000000000000001000b014800000004000000000000000100010101000000010000000000000003000101010000000600000000000000050003000400000001000000000000000600010001000000030000000000000002000601040000000000c0bf0000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000003000000000000000200060104000000000000bf0000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c00000003000101010000000600000000000000050003000400000002000000000000000600010001000000030000000000000002000601040000000000003f0000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000020000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000020000000000000004000900020000006571000000000000050003000400000001000000000000000600010001000000030000000000000002000601040000000000c03f0000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000020000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000050000000000000004000900070000006465736b2d65710005000300040000000000000000000000060001000100000003000000000000000200060104000000000020400000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b01580000000500000000000000010001010100000003000000000000000200090007000000746f2d6d61696e000300010101000000060000000000000005000300040000000100000000000000060001000100000003000000000000000200060104000000000060400000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000020000000000000001000b0158000000050000000000000001000101010000000400000000000000020009000300000067727000000000000300010101000000060000000000000005000300040000000100000000000000060001000100000003000000000000000200060104000000000090400000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000030000000000000001000b014800000004000000000000000100010101000000010000000000000003000101010000000600000000000000050003000400000001000000000000000600010001000000010000000000000002000601040000000000c0bf000000000300030004000000000000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000001000000000000000200060104000000000000bf000000000300030004000000000000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c00000003000101010000000600000000000000050003000400000002000000000000000600010001000000010000000000000002000601040000000000003f000000000300030004000000000000000000000001000b01b800000002000000000000000100020102000000000800000000000002000b0198000000030000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000020000000000000004000900020000006571000000000000050003000400000001000000000000000600010001000000010000000000000002000601040000000000c03f000000000300030004000000000000000000000001000b01b800000002000000000000000100020102000000000800000000000002000b0198000000030000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000050000000000000004000900070000006465736b2d6571000500030004000000000000000000000006000100010000000100000000000000020006010400000000002040000000000300030004000000000000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000003000000000000000200090007000000746f2d6d61696e00030001010100000006000000000000000500030004000000010000000000000006000100010000000100000000000000020006010400000000006040000000000300030004000000000000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b015800000005000000000000000100010101000000040000000000000002000900030000006772700000000000030001010100000006000000000000000500030004000000010000000000000006000100010000000100000000000000020006010400000000009040000000000300030004000000000000000000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000030000000000000001000b014800000004000000000000000100010101000000010000000000000003000101010000000600000000000000050003000400000001000000000000000600010001000000020000000000000002000601040000000000c0bf000000000300030004000000000000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000002000000000000000200060104000000000000bf000000000300030004000000000000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c00000003000101010000000600000000000000050003000400000002000000000000000600010001000000020000000000000002000601040000000000003f000000000300030004000000000000000000000001000b01b800000002000000000000000100020102000000000800000000000002000b0198000000030000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000020000000000000004000900020000006571000000000000050003000400000001000000000000000600010001000000020000000000000002000601040000000000c03f000000000300030004000000000000000000000001000b01b800000002000000000000000100020102000000000800000000000002000b0198000000030000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000050000000000000004000900070000006465736b2d6571000500030004000000000000000000000006000100010000000200000000000000020006010400000000002040000000000300030004000000000000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000003000000000000000200090007000000746f2d6d61696e00030001010100000006000000000000000500030004000000010000000000000006000100010000000200000000000000020006010400000000006040000000000300030004000000000000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b015800000005000000000000000100010101000000040000000000000002000900030000006772700000000000030001010100000006000000000000000500030004000000010000000000000006000100010000000200000000000000020006010400000000009040000000000300030004000000000000000000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000030000000000000001000b014800000004000000000000000100010101000000010000000000000003000101010000000600000000000000050003000400000001000000000000000600010001000000030000000000000002000601040000000000c0bf000000000300030004000000000000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000003000000000000000200060104000000000000bf000000000300030004000000000000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c00000003000101010000000600000000000000050003000400000002000000000000000600010001000000030000000000000002000601040000000000003f000000000300030004000000000000000000000001000b01b800000002000000000000000100020102000000000800000000000002000b0198000000030000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000020000000000000004000900020000006571000000000000050003000400000001000000000000000600010001000000030000000000000002000601040000000000c03f000000000300030004000000000000000000000001000b01b800000002000000000000000100020102000000000800000000000002000b0198000000030000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000050000000000000004000900070000006465736b2d6571000500030004000000000000000000000006000100010000000300000000000000020006010400000000002040000000000300030004000000000000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000003000000000000000200090007000000746f2d6d61696e00030001010100000006000000000000000500030004000000010000000000000006000100010000000300000000000000020006010400000000006040000000000300030004000000000000000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b015800000005000000000000000100010101000000040000000000000002000900030000006772700000000000030001010100000006000000000000000500030004000000010000000000000006000100010000000300000000000000020006010400000000009040000000000300030004000000000000000000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000030000000000000001000b014800000004000000000000000100010101000000010000000000000003000101010000000600000000000000050003000400000001000000000000000600010001000000010000000000000002000601040000000000c0bf000000000300030004000000e00100000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000001000000000000000200060104000000000000bf000000000300030004000000e00100000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c00000003000101010000000600000000000000050003000400000002000000000000000600010001000000010000000000000002000601040000000000003f000000000300030004000000e00100000000000001000b01b800000002000000000000000100020102000000000800000000000002000b0198000000030000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000020000000000000004000900020000006571000000000000050003000400000001000000000000000600010001000000010000000000000002000601040000000000c03f000000000300030004000000e00100000000000001000b01b800000002000000000000000100020102000000000800000000000002000b0198000000030000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000050000000000000004000900070000006465736b2d6571000500030004000000000000000000000006000100010000000100000000000000020006010400000000002040000000000300030004000000e00100000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000003000000000000000200090007000000746f2d6d61696e00030001010100000006000000000000000500030004000000010000000000000006000100010000000100000000000000020006010400000000006040000000000300030004000000e00100000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b015800000005000000000000000100010101000000040000000000000002000900030000006772700000000000030001010100000006000000000000000500030004000000010000000000000006000100010000000100000000000000020006010400000000009040000000000300030004000000e00100000000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000030000000000000001000b014800000004000000000000000100010101000000010000000000000003000101010000000600000000000000050003000400000001000000000000000600010001000000020000000000000002000601040000000000c0bf000000000300030004000000e00100000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000002000000000000000200060104000000000000bf000000000300030004000000e00100000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c00000003000101010000000600000000000000050003000400000002000000000000000600010001000000020000000000000002000601040000000000003f000000000300030004000000e00100000000000001000b01b800000002000000000000000100020102000000000800000000000002000b0198000000030000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000020000000000000004000900020000006571000000000000050003000400000001000000000000000600010001000000020000000000000002000601040000000000c03f000000000300030004000000e00100000000000001000b01b800000002000000000000000100020102000000000800000000000002000b0198000000030000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000050000000000000004000900070000006465736b2d6571000500030004000000000000000000000006000100010000000200000000000000020006010400000000002040000000000300030004000000e00100000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000003000000000000000200090007000000746f2d6d61696e00030001010100000006000000000000000500030004000000010000000000000006000100010000000200000000000000020006010400000000006040000000000300030004000000e00100000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b015800000005000000000000000100010101000000040000000000000002000900030000006772700000000000030001010100000006000000000000000500030004000000010000000000000006000100010000000200000000000000020006010400000000009040000000000300030004000000e00100000000000001000b019800000002000000000000000100020102000000000800000000000002000b0178000000030000000000000001000b014800000004000000000000000100010101000000010000000000000003000101010000000600000000000000050003000400000001000000000000000600010001000000030000000000000002000601040000000000c0bf000000000300030004000000e00100000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000040000000000000005000300040000000300000000000000060001000100000003000000000000000200060104000000000000bf000000000300030004000000e00100000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000002000000000000000200090005000000766f63616c00000003000101010000000600000000000000050003000400000002000000000000000600010001000000030000000000000002000601040000000000003f000000000300030004000000e00100000000000001000b01b800000002000000000000000100020102000000000800000000000002000b0198000000030000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000020000000000000004000900020000006571000000000000050003000400000001000000000000000600010001000000030000000000000002000601040000000000c03f000000000300030004000000e00100000000000001000b01b800000002000000000000000100020102000000000800000000000002000b0198000000030000000000000001000b01680000000600000000000000010001010100000002000000000000000200090005000000766f63616c0000000300010101000000050000000000000004000900070000006465736b2d6571000500030004000000000000000000000006000100010000000300000000000000020006010400000000002040000000000300030004000000e00100000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b01580000000500000000000000010001010100000003000000000000000200090007000000746f2d6d61696e00030001010100000006000000000000000500030004000000010000000000000006000100010000000300000000000000020006010400000000006040000000000300030004000000e00100000000000001000b01a800000002000000000000000100020102000000000800000000000002000b0188000000030000000000000001000b015800000005000000000000000100010101000000040000000000000002000900030000006772700000000000030001010100000006000000000000000500030004000000010000000000000006000100010000000300000000000000020006010400000000009040000000000300030004000000e001000000000000" } diff --git a/crates/protocol/tests/set_control_frames.rs b/crates/protocol/tests/set_control_frames.rs index 1d6a7a394..25f467bc1 100644 --- a/crates/protocol/tests/set_control_frames.rs +++ b/crates/protocol/tests/set_control_frames.rs @@ -48,6 +48,7 @@ fn edit_json(edit: &SessionEdit) -> String { address, value, ramp_samples, + expected_effect, } = edit else { panic!("the fixture holds SET_CONTROL edits only"); @@ -73,9 +74,13 @@ fn edit_json(edit: &SessionEdit) -> String { } }; let ramp = ramp_samples.map_or_else(|| "null".to_owned(), |ramp| ramp.to_string()); + let expected = expected_effect.as_ref().map_or_else( + || "null".to_owned(), + |effect| format!("\"{}\"", effect.as_str()), + ); format!( "{{ \"owner\": {owner}, \"section\": {section}, \"control\": {}, \"lane\": \"{}\", \ - \"valueBits\": \"{:08x}\", \"rampSamples\": {ramp} }}", + \"valueBits\": \"{:08x}\", \"rampSamples\": {ramp}, \"expectedEffect\": {expected} }}", address.control, lane_name(address.lane), value.to_bits(), @@ -91,6 +96,7 @@ fn set_control( ramp_samples: Option, ) -> SessionEdit { SessionEdit::SetControl { + expected_effect: None, address: ControlAddress { owner, section, @@ -199,6 +205,32 @@ fn commands() -> Vec { ) }) .collect(); + // #1678: an insert and a console edit that name the native effect they were written for; the + // identifiers' lengths cover the padding residues of the seventh field. + let expected_effect: Vec = [ + ("insert", "miso.parametric-eq"), + ("console", "miso.compressor"), + ("insert", "miso.true-peak-limiter"), + ("console", "miso.transient-shaper"), + ] + .into_iter() + .map(|(rack, effect)| { + let section = if rack == "insert" { + Section::Inserts(id("eq")) + } else { + Section::Console(id("desk-eq")) + }; + set_control( + Owner::Strip(id("vocal")), + section, + 4, + ParameterChannel::Both, + -2.0, + None, + ) + .with_expected_effect(id(effect)) + }) + .collect(); vec![ Command { name: "one_edit_no_ramp", @@ -251,6 +283,12 @@ fn commands() -> Vec { expected_revision: u64::MAX - 1, edits: odd_values, }, + Command { + name: "expected_effect", + request_id: 101, + expected_revision: 13, + edits: expected_effect, + }, Command { name: "every_shape_lane_and_ramp", request_id: 100, @@ -288,16 +326,19 @@ fn command_frame(command: &Command) -> Vec { address: got_address, value: got_value, ramp_samples: got_ramp, + expected_effect: got_effect, }, SessionEdit::SetControl { address: want_address, value: want_value, ramp_samples: want_ramp, + expected_effect: want_effect, }, ) => { assert_eq!(got_address, want_address); assert_eq!(got_value.to_bits(), want_value.to_bits()); assert_eq!(got_ramp, want_ramp); + assert_eq!(got_effect, want_effect); } _ => panic!("a SET_CONTROL edit decodes as itself"), } diff --git a/docs/CONTROL_PROTOCOL_CONFORMANCE.md b/docs/CONTROL_PROTOCOL_CONFORMANCE.md index d7a9ccd07..334c8fabd 100644 --- a/docs/CONTROL_PROTOCOL_CONFORMANCE.md +++ b/docs/CONTROL_PROTOCOL_CONFORMANCE.md @@ -1,6 +1,6 @@ # MISO control protocol conformance record -This manifest records Issue 005 non-benchmark evidence and its limits. The normative corpus is `complete_schema_corpus()`: 46 canonical frames covering 11 commands (including one transaction with all 35 edit opcodes), 13 success responses, 16 non-OK statuses including typed backpressure, and six events. Its FNV-1a-64 label-and-byte hash is `cc9a7bcc502c4936` (`COMPLETE_SCHEMA_HASH`; re-pinned for the fold of `OBSERVATION_ENDED` to one event per commit in #1584 D6a (root ruling A): the event carries a repeated key message, and the corpus frame carries two keys; re-pinned for `OBSERVATION_ENDED` in #1584: the corpus gains `event.observation_ended` (`8011`) and the capabilities response lists event `8011`; re-pinned for the retirement of `AUTOMATION_CANCELED` in #1628: the event `8002` is refused and never reallocated, so the corpus loses `event.automation_canceled` and the capabilities response lists no event `8002`; re-pinned for the retirement of `AUTOMATION_ENQUEUE` in #1627: `0006`, its success response, statuses 15 and 16 and capability fields 9, 12, 19 and 20 are refused and never reallocated, so the corpus loses the command frame, its response and the two status frames and the capabilities response has 23 fields; re-pinned for `RESPONSE_GET` in #1573: `000e`, one command frame and two response frames (a curve reply and a lone tag), and the capability registry lists `000e` with bit 14; re-pinned for the transaction response of the monitor edit path in the batch 4 review fold: the corpus gains `response.session_transaction_monitor`, `edit_path` 4; re-pinned for the retirement of the thirteen per-kind value ops in #1561: opcodes `0008`, `0203`, `020a`, `020b`, `020d`-`0210`, `0504`-`0507` and `0702` are refused and never reallocated, 35 edits; re-pinned for `TransactionApplied` field 2, `edit_path`, in #1313; re-pinned for `SetControl` in #1553: opcode `0800` appended, 48 edits; re-pinned for the control list in #1551: `CONTROL_LIST_GET` and `CONTROL_STATE_GET` (`000c`, `000d`) replace the retired `0004` and `0005`, with a list page of every kind of row and the state page of its control rows, re-pinned for the BLAKE3 source-identity spelling in #787, for decision 12's track encoding in #1093: field 7 carries the inserts, the retired fields 6 and 8 are gone, and field 11 carries the console entries, for decision 12's console edits in #1094: `SetConsole` `0007` and `SetTrackConsole` `0211`, for the submix strip in #1199: submix fields 2, 4, 5, 6, for the submix console in #1202: submix field 3 repeats the console entry, and for the bus tap in #1203: a tag-2 route source carries a required tap, and the corpus's `SetRouteSource` value is a tapped submix source, and for the route switch in #1216: route field `mute` (field 6) and opcode `0506` (`SetRouteMute`), and for the send's follow in #1218: route field `follows_mute` (field 7) and opcode `0507` (`SetRouteFollowsMute`), and for VCA groups in #1241: the VCA message (`id` 1, `fader` 2, repeated `members` 3) and opcodes `0700`-`0702` (`UpsertVca`, `RemoveVca`, `SetVcaFader`), and for the per-edit ramp length in #1394: #1394 appends optional `ramp_samples` to the live-value edits, and the corpus's `SetTrackFader` carries one, and for the session's control smoothing in #1365: #1365 appends `SetControlSmoothing` `0008`, and the corpus's transaction carries one). Decision 12's retired and refused codes each have a wire row in `retired_code_rows()`, and so do the retired message IDs `0004`, `0005`, `0006` and `8002` (#1551, #1627, #1628; `0006` is a frame with the payload the command had, `8002` an event ID under a command header: the decoder's `UNSUPPORTED_MESSAGE`, with no response frame because a message ID that decodes to no message has no safe request identity): rack codes 1 and 3 in every rack-addressed edit and in an automation target, track fields 6 and 8 in both flag forms, each structural or declaration edit addressed at `console`, each of the thirteen value-op codes retired by #1561 (an unallocated opcode, refused at decode), and a `SetTrackConsole` in a transaction with no `SetConsole` before it that changes the console (`session.edit.value_op_retired`); an endpoint over `console_session_fixture()` answers each with its status and commits nothing. Auditable seed frames are checked in under [`fuzz/corpus`](../fuzz/corpus/README.md). +This manifest records Issue 005 non-benchmark evidence and its limits. The normative corpus is `complete_schema_corpus()`: 46 canonical frames covering 11 commands (including one transaction with all 35 edit opcodes), 13 success responses, 16 non-OK statuses including typed backpressure, and six events. Its FNV-1a-64 label-and-byte hash is `0c7196f86abe6d57` (`COMPLETE_SCHEMA_HASH`; re-pinned once in batch 13b, from `cc9a7bcc502c4936`, for two changes together: the retirement of the stored transport position in #1635 (`TRANSPORT_SET` field 2, the snapshot's field 2 and the `TRANSPORT_STATE` event's field 3 are refused and never reallocated, so the command frame carries the state alone and the snapshot and event frames the state and the effective sample, 46 frames still) and the optional expected effect of the `SET_CONTROL` address in #1678 (the transaction appends a `SetControl` on an insert row that carries it); re-pinned for the fold of `OBSERVATION_ENDED` to one event per commit in #1584 D6a (root ruling A): the event carries a repeated key message, and the corpus frame carries two keys; re-pinned for `OBSERVATION_ENDED` in #1584: the corpus gains `event.observation_ended` (`8011`) and the capabilities response lists event `8011`; re-pinned for the retirement of `AUTOMATION_CANCELED` in #1628: the event `8002` is refused and never reallocated, so the corpus loses `event.automation_canceled` and the capabilities response lists no event `8002`; re-pinned for the retirement of `AUTOMATION_ENQUEUE` in #1627: `0006`, its success response, statuses 15 and 16 and capability fields 9, 12, 19 and 20 are refused and never reallocated, so the corpus loses the command frame, its response and the two status frames and the capabilities response has 23 fields; re-pinned for `RESPONSE_GET` in #1573: `000e`, one command frame and two response frames (a curve reply and a lone tag), and the capability registry lists `000e` with bit 14; re-pinned for the transaction response of the monitor edit path in the batch 4 review fold: the corpus gains `response.session_transaction_monitor`, `edit_path` 4; re-pinned for the retirement of the thirteen per-kind value ops in #1561: opcodes `0008`, `0203`, `020a`, `020b`, `020d`-`0210`, `0504`-`0507` and `0702` are refused and never reallocated, 35 edits; re-pinned for `TransactionApplied` field 2, `edit_path`, in #1313; re-pinned for `SetControl` in #1553: opcode `0800` appended, 48 edits; re-pinned for the control list in #1551: `CONTROL_LIST_GET` and `CONTROL_STATE_GET` (`000c`, `000d`) replace the retired `0004` and `0005`, with a list page of every kind of row and the state page of its control rows, re-pinned for the BLAKE3 source-identity spelling in #787, for decision 12's track encoding in #1093: field 7 carries the inserts, the retired fields 6 and 8 are gone, and field 11 carries the console entries, for decision 12's console edits in #1094: `SetConsole` `0007` and `SetTrackConsole` `0211`, for the submix strip in #1199: submix fields 2, 4, 5, 6, for the submix console in #1202: submix field 3 repeats the console entry, and for the bus tap in #1203: a tag-2 route source carries a required tap, and the corpus's `SetRouteSource` value is a tapped submix source, and for the route switch in #1216: route field `mute` (field 6) and opcode `0506` (`SetRouteMute`), and for the send's follow in #1218: route field `follows_mute` (field 7) and opcode `0507` (`SetRouteFollowsMute`), and for VCA groups in #1241: the VCA message (`id` 1, `fader` 2, repeated `members` 3) and opcodes `0700`-`0702` (`UpsertVca`, `RemoveVca`, `SetVcaFader`), and for the per-edit ramp length in #1394: #1394 appends optional `ramp_samples` to the live-value edits, and the corpus's `SetTrackFader` carries one, and for the session's control smoothing in #1365: #1365 appends `SetControlSmoothing` `0008`, and the corpus's transaction carries one). Decision 12's retired and refused codes each have a wire row in `retired_code_rows()`, and so do the retired message IDs `0004`, `0005`, `0006` and `8002` (#1551, #1627, #1628; `0006` is a frame with the payload the command had, `8002` an event ID under a command header: the decoder's `UNSUPPORTED_MESSAGE`, with no response frame because a message ID that decodes to no message has no safe request identity): rack codes 1 and 3 in every rack-addressed edit and in an automation target, track fields 6 and 8 in both flag forms, `TRANSPORT_SET` field 2 in both flag forms (#1635), each structural or declaration edit addressed at `console`, each of the thirteen value-op codes retired by #1561 (an unallocated opcode, refused at decode), and a `SetTrackConsole` in a transaction with no `SetConsole` before it that changes the console (`session.edit.value_op_retired`); an endpoint over `console_session_fixture()` answers each with its status and commits nothing. Auditable seed frames are checked in under [`fuzz/corpus`](../fuzz/corpus/README.md). | Evidence | Recorded result | Limitation | | --- | --- | --- | diff --git a/docs/CONTROL_PROTOCOL_REGISTRY.md b/docs/CONTROL_PROTOCOL_REGISTRY.md index 2a4fdedd7..fa6e84e43 100644 --- a/docs/CONTROL_PROTOCOL_REGISTRY.md +++ b/docs/CONTROL_PROTOCOL_REGISTRY.md @@ -12,7 +12,9 @@ Message IDs `0004` (parameter metadata get) and `0005` (parameter state get) are Message ID `0006` (automation enqueue) is **retired** the same way (#1627): every sample-timed change is a stored automation edit (`0600`-`0603`), so the command, its success response, statuses `15` and `16`, capability flag bit 5 and capability fields `9`, `12`, `19` and `20` are retired. The decoder refuses `0006` as `UNSUPPORTED_MESSAGE` before any state changes (it is in `RETIRED_MESSAGE_IDS` and pinned by a conformance row), no capability registry lists it or sets bit 5 (a set bit 5 is an unknown flag and refused), a status number `15` or `16` is an invalid status, and a capabilities response that carries field `9`, `12`, `19` or `20` is refused in either flag form, never skipped as an unknown optional field. None is ever reallocated or renumbered. The capabilities record has 23 fields. -Event ID `8002` (automation canceled) is **retired** with it (#1628): with no batch to admit there is nothing to cancel, so a revision change or a transport locate cancels nothing and a transaction commit reserves only its `SESSION_COMMITTED`. The decoder refuses `8002` as `UNSUPPORTED_MESSAGE` (it is in `RETIRED_MESSAGE_IDS` and pinned by a conformance row), no capabilities record lists it, and the session family is command `0003` with event `8001`. It is never reallocated or renumbered. +Event ID `8002` (automation canceled) is **retired** with it (#1628): with no batch to admit there is nothing to cancel, so a revision change or a transport set cancels nothing and a transaction commit reserves only its `SESSION_COMMITTED`. The decoder refuses `8002` as `UNSUPPORTED_MESSAGE` (it is in `RETIRED_MESSAGE_IDS` and pinned by a conformance row), no capabilities record lists it, and the session family is command `0003` with event `8001`. It is never reallocated or renumbered. + +The stored transport position is **retired** (#1635): the protocol keeps no playhead that nothing plays, because the engine has one playhead, which the host moves with source seeks (and, later, the session seek). `TRANSPORT_SET` (`0008`) field `2`, the transport snapshot's field `2` and the `TRANSPORT_STATE` event's (`8010`) field `3` are refused at decode in either flag form (an optional one as `MALFORMED_FRAME`, a mandatory one as `UNKNOWN_REQUIRED_FIELD`), never skipped as unknown optional fields, never reallocated and never renumbered; the remaining field IDs keep their numbers. A `TRANSPORT_SET` that carries one changes nothing and queues no event, and its conformance rows are `retired.transport_set_field_2.*`. The transport state (stopped or playing) stays endpoint-local and readable. The automation queue is **retired** with its last producer and consumer (#1629): nothing enqueues a batch, so the queue, its 32-byte record, its density and frontier admission and its configuration are deleted. Queue kind `2` (a `BACKPRESSURE` payload naming it fails decode) and counters `2` (`AUTOMATION_BACKPRESSURE`), `11` (`LATE_AUTOMATION`), `12` (`CANCELED_AUTOMATION`), `13` (`AUTOMATION_TIME_PAST`) and `14` (`AUTOMATION_ORDER_REJECT`) are retired the same way: a `TELEMETRY_CONFIGURE` naming a retired counter is refused as `INVALID_FIELD`, a `COUNTERS_GET` naming one is `NOT_FOUND` (as for any counter the endpoint does not report), and a counters snapshot never carries one. None is ever reallocated or renumbered; the next counter takes a new number. Conformance rows pin each refusal. @@ -33,8 +35,8 @@ Status IDs are: `0` OK, `1` malformed frame, `2` unsupported version, `3` unsupp | `0001` | empty, revision-any | v1 min/max, effective frame/TLV/string/depth limits, page/transaction limits, queue/replay caps, packed command/event IDs, capability flags (23 fields; `9`, `12`, `19` and `20` are retired) | | `0002` | `1:offset U64 R`, `2:max-bytes U32 R` | `1:total U64 R`, `2:offset U64 R`, `3:canonical-JSON BYTES R`, `4:eof BOOL R` | | `0003` | `1:SessionEdit MESSAGE R*`; exact revision, nonempty | `1:applied-operations U32 R`, `2:edit-path U8 R` | -| `0007` | empty | transport snapshot below | -| `0008` | state `1:U8 R`, absolute position `2:U64 O`; exact revision | transport snapshot below | +| `0007` | empty | transport snapshot: state `1:U8 R`, effective sample `3:U64 R` (field `2`, the position, is retired, #1635) | +| `0008` | state `1:U8 R`; exact revision. Field `2` (the absolute position) is **retired** (#1635) | transport snapshot as for `0007` | | `0009` | meter handles `1:PACKED_U32 R`, meter period `2:U32 R`, counter IDs `3:PACKED_U32 R`, counter period `4:U32 R`, diagnostics enabled `5:BOOL R`, minimum severity `6:U8 R`; exact revision | canonical echo of those six fields | | `000a` | all `1:BOOL R`, IDs `2:PACKED_U32 O` only when not all | observed sample `1:U64 R`, `CounterValue MESSAGE R*` | | `000b` | after-sequence `1:U64 R`, limit `2:U16 R`, minimum severity `3:U8 R` | last sequence `1:U64 R`, eof `2:BOOL R`, diagnostic `3:MESSAGE R*` | @@ -91,7 +93,7 @@ A control row's own rules: all numbers are finite and `minimum <= maximum`; choi ## Events -Reliable `8001` contains sequence, origin request ID, previous revision, and operation count. Reliable `8010` contains sequence, state, position, effective sample, and optional origin request ID. Reliable `8011` (`OBSERVATION_ENDED`, #1584) is the one event of a commit that ended host-local observation subscriptions, and it carries every ended key of that commit. It contains sequence `1:U64 R` and one repeated key message `2:MESSAGE R` per ended tap, at least one. A key message holds strip ID `1:UTF8 R`, section `2:U8 R` (inserts `2` or console `5`, the rack codes), the insert's effect ID or the console slot ID `3:UTF8 R`, and the effect-local tap ID `4:U32 R`. The keys are in strictly ascending order: the strip ID, then console slots before inserts, then the effect ID, then the tap ID; so a key never repeats. The event header's revision is the revision that ended the taps. Both IDs are stable IDs, and any other section code, an empty key list or a key out of order is `INVALID_FIELD`. It is advertised with `8001`, in the session family, when the endpoint keeps observation subscriptions. Lossy `8020` contains observed sample plus the fixed 16-byte meter array. Lossy `8021` contains observed sample plus ascending counter values. Reliable `8030` contains one diagnostic with provider sequence. Events have header request ID zero; reliable events have endpoint-monotonic payload sequence. +Reliable `8001` contains sequence, origin request ID, previous revision, and operation count. Reliable `8010` contains sequence `1:U64 R`, state `2:U8 R`, effective sample `4:U64 R`, and optional origin request ID `5:U64 O`; field `3`, the position, is **retired** (#1635). Reliable `8011` (`OBSERVATION_ENDED`, #1584) is the one event of a commit that ended host-local observation subscriptions, and it carries every ended key of that commit. It contains sequence `1:U64 R` and one repeated key message `2:MESSAGE R` per ended tap, at least one. A key message holds strip ID `1:UTF8 R`, section `2:U8 R` (inserts `2` or console `5`, the rack codes), the insert's effect ID or the console slot ID `3:UTF8 R`, and the effect-local tap ID `4:U32 R`. The keys are in strictly ascending order: the strip ID, then console slots before inserts, then the effect ID, then the tap ID; so a key never repeats. The event header's revision is the revision that ended the taps. Both IDs are stable IDs, and any other section code, an empty key list or a key out of order is `INVALID_FIELD`. It is advertised with `8001`, in the session family, when the endpoint keeps observation subscriptions. Lossy `8020` contains observed sample plus the fixed 16-byte meter array. Lossy `8021` contains observed sample plus ascending counter values. Reliable `8030` contains one diagnostic with provider sequence. Events have header request ID zero; reliable events have endpoint-monotonic payload sequence. A stored automation segment applies on `[start,end)` and holds its end value until replaced; the session schema states this hold rule (`SESSION_SCHEMA_V1.md`, #1626). @@ -124,7 +126,7 @@ A stored automation segment applies on `[start,end)` and holds its end value unt | `0504`–`0507` | retired (#1561) | a route's matrix, gain, mute and follows-mute are `SET_CONTROL` rows (`follows_mute` is inactive on a route into the output: `control.inactive`) | | `0600`–`0603` | upsert/remove automation; set target/segments | automation or automation ID plus target; ordered repeated segment | | `0700`–`0701` | upsert/remove VCA | VCA; VCA ID (#1241: `0700` is a plain upsert by ID; `0701` at an unknown ID is `session.edit.not_found`; `0702`, set VCA fader, is retired by #1561: a VCA's offset and mute are `SET_CONTROL` rows; membership, namespace, range and acyclicity are validated on the final candidate, so removing a VCA, track or submix that a VCA still lists refuses the whole transaction with `reference.missing_entity` unless the same transaction rewrites that VCA, and a cycle refuses with `vca.cycle`. A VCA message spends one TLV each on `id` and `fader` and one per member, so under the default `ProtocolLimits::max_tlv_count` (1,024 per message) one `0700` carries at most 1,022 members, the same bound `SetTrackEffectOrder` has; membership has no incremental opcode, so a larger VCA needs both endpoints to raise that configured limit) | -| `0800` | set control | `1`: control address (message, `control` and `lane` required), `2`: value `F32` (any bit pattern; a non-finite value is refused by the validator), `3`: `ramp_samples U32 O` (#1553, below) | +| `0800` | set control | `1`: control address (message, `control` and `lane` required; an optional `7:expected-effect UTF8 O`, #1678, below), `2`: value `F32` (any bit pattern; a non-finite value is refused by the validator), `3`: `ramp_samples U32 O` (#1553, below) | Strip addressing (#1204). The strip opcodes (`0204`–`0209`, `020c`, `0211`, and the retired `0203`, `020a`, `020b`, `020d`–`0210`) address a **strip ID**, and nothing else about them changed: no opcode, field, code or wire position moved, and the field once named "track ID" is the same first field with the same type. @@ -138,7 +140,9 @@ Control smoothing (#1365, decision 15 D15-1, D15-3; `0008` retired by #1561, dec Retired value ops (#1561, decision 17, C5). `SET_CONTROL` is the one way to set a value. The thirteen per-kind value ops `0008`, `0203`, `020a`, `020b`, `020d`, `020e`, `020f`, `0210`, `0504`, `0505`, `0506`, `0507` and `0702` are retired: each is refused at decode as every unallocated code is (`MALFORMED_FRAME`, whatever the payload holds), and none is ever reallocated. `0211` stays allocated for its structural use only: a transaction that carries it with no `0007` before it that changes the console is refused whole at that edit with `VALIDATION_FAILED` and `session.edit.value_op_retired`, and nothing commits; a console knob's value is a `SET_CONTROL`. -Set control (#1553, decision 17, C5). `0800` sets any control of the control list by its stable address, with the address message above (`control` and `lane` required; the owner ID, slot ID and codes follow the list's rules, and an address that breaks them is `INVALID_FIELD`). The value is any `F32`; a non-finite value is decoded and refused by the validator, so the refusal names the edit. The core's one validator runs inside the edit, before the model changes and before the compile: a refused edit refuses the whole transaction with `VALIDATION_FAILED` and a diagnostic at `edits[i]` whose code is the control code itself: `control.address.unknown`, `control.read_only`, `control.inactive`, `control.lane`, `control.domain`, `control.ramp.fixed`, `control.ramp.conflict` or `control.monitor` (solo is held by the host and has no session writer: the refusal belongs to the one-call path only, `controls::set` on a monitor row; the prepared path takes a solo as a monitor write and replies edit path `4`, monitor). Whether the committed edit is live or a rebuild is the classifier's answer for the resulting model, exactly as for any other edit that writes the same field. +Set control (#1553, decision 17, C5). `0800` sets any control of the control list by its stable address, with the address message above (`control` and `lane` required; the owner ID, slot ID and codes follow the list's rules, and an address that breaks them is `INVALID_FIELD`). The value is any `F32`; a non-finite value is decoded and refused by the validator, so the refusal names the edit. The core's one validator runs inside the edit, before the model changes and before the compile: a refused edit refuses the whole transaction with `VALIDATION_FAILED` and a diagnostic at `edits[i]` whose code is the control code itself: `control.address.unknown`, `control.read_only`, `control.inactive`, `control.lane`, `control.domain`, `control.ramp.fixed`, `control.ramp.conflict`, `control.effect.changed` or `control.monitor` (solo is held by the host and has no session writer: the refusal belongs to the one-call path only, `controls::set` on a monitor row; the prepared path takes a solo as a monitor write and replies edit path `4`, monitor). Whether the committed edit is live or a rebuild is the classifier's answer for the resulting model, exactly as for any other edit that writes the same field. + +Expected effect (#1678). The address message of `0800` has one more optional field than the list's, `7:expected-effect UTF8 O`: the stable ID of the native effect the edit was written for. It is allowed only when the section is `inserts` or `console` (any other section is `INVALID_FIELD`, never ignored) and must be a stable ID. The row's numeric `control` depends on the effect the insert or console slot runs, and a rebuild can replace that effect, so an edit that names an expected effect is checked, inside the edit and before any write, against the effect the candidate model runs at that slot or insert: a different native effect, or a `cid` one, refuses the whole transaction with `VALIDATION_FAILED` and `control.effect.changed` at `edits[i]`. An address that names no insert or slot is still `control.address.unknown`. An edit that names no expected effect applies to whatever runs there, as before. The list's own address message and a stored automation target have no seventh field. The check is the core's, so the browser host and the C ABI refuse alike. Per-edit ramp length (#1394, decision 15 D15-1, D15-3). Each live-value edit (`0007`, `0211` and `0800`) appends one optional field, `ramp_samples` (`U32`), after its last field: an in-place V1 field, with no new opcode and no ABI feature bit. Absent means the session's `control_smoothing`, and an explicit `0` is legal and steps that edit only; the decoder keeps the two apart. A value above the committed `sample_rate_hz` (one second) refuses the whole transaction before anything commits with `VALIDATION_FAILED` and diagnostic `session.edit.ramp_out_of_range` at `edits[i]`, the offending edit's index; the bound uses the rate the transaction commits. The length is transaction metadata: never model state, never in canonical JSON, and the next edit without one uses the session default. diff --git a/docs/CONTROL_PROTOCOL_SEMANTICS.md b/docs/CONTROL_PROTOCOL_SEMANTICS.md index 7e0c4e33f..22656ce05 100644 --- a/docs/CONTROL_PROTOCOL_SEMANTICS.md +++ b/docs/CONTROL_PROTOCOL_SEMANTICS.md @@ -14,15 +14,15 @@ All v1 operations are absolute and idempotent. A new logical command has a nonze ## Automation and events -There is one way to make a change at a sample time: a stored automation edit (`SessionEdit` opcodes `0600`-`0603`), which the engine renders from the session's stored automation table (#1058). The command `AUTOMATION_ENQUEUE` (`0006`), which once admitted a batch of timed records to a queue, is **retired** (#1627, under the owner ruling in `rulings/engine-footprint-2026-09-28.md`: sample-timed delivery had no product consumer, and a host that admitted a batch acked work that no PCM reached). A frame under message ID `0006` is refused at decode as `UNSUPPORTED_MESSAGE`, before the revision check, so no revision, event, queue or replay entry changes and no ack can precede a drop; no endpoint lists `0006`, flag bit 5 or the capability fields that described its admission (9, 12, 19 and 20), and statuses 15 (`TIME_IN_PAST`) and 16 (`AUTOMATION_ORDER`) are retired with it. Every retired code is refused, never reallocated and never renumbered (see the [registry](CONTROL_PROTOCOL_REGISTRY.md)). Sample-accurate application of point, step, linear and exponential records is the protocol capability named in `EFFECT_CONTRACT_V1.md` ("V1 runtime automation is `Point` spans …"). +There is one way to make a change at a sample time: a stored automation edit (`SessionEdit` opcodes `0600`-`0603`), which the engine renders from the session's stored automation table (#1058). The command `AUTOMATION_ENQUEUE` (`0006`), which once admitted a batch of timed records to a queue, is **retired** (#1627, under the owner ruling in `rulings/engine-footprint-2026-09-28.md`: sample-timed delivery had no product consumer, and a host that admitted a batch acked work that no PCM reached). A frame under message ID `0006` is refused at decode as `UNSUPPORTED_MESSAGE`, before the revision check, so no revision, event, queue or replay entry changes and no ack can precede a drop; no endpoint lists `0006`, flag bit 5 or the capability fields that described its admission (9, 12, 19 and 20), and statuses 15 (`TIME_IN_PAST`) and 16 (`AUTOMATION_ORDER`) are retired with it. Every retired code is refused, never reallocated and never renumbered (see the [registry](CONTROL_PROTOCOL_REGISTRY.md)). The `Step`, `Linear` and `Exponential` span kinds and the `Sample` automation rate are retired with it (#1636, `EFFECT_CONTRACT_V1.md`, "V1 runtime automation"). **Delivery history (v1).** The delivery of the command was planned by #140, descoped by the owner ruling in `rulings/engine-footprint-2026-09-28.md`, and its unwired partial implementation (the #460 delivery service, its controller facade and the host-core builtin-batch and scalar-point endpoints) was deleted by #1056. #1315 then refused the command `UNAVAILABLE` on both hosts behind a provider feature switch that nothing set; #1627 retired the command and removed the switch. Two outcomes have their own owners instead. Rendering the session's stored automation table is #1058. Live value edits on the C ABI are delivered by #1053: a committed `SESSION_TRANSACTION_APPLY` whose delta is only live values is lowered onto the running plan's live-control lanes, with the same response and events as before. The live values are a track's fader, mute and pan or matrix values (#1257); a changed value of a track effect parameter whose `automation_rate` is `Block`, on a console-slot entry or an insert (#1264), the parametric EQ's live parameters included, which ride prepared targets (#1265); and a track effect's bypass, except the delay's and the multiband compressor's, which rebuild (#1266). A transaction that changes only the session ID, a profile's id or the stored automation table commits with no replacement and no live value (#1260). Every other transaction still replaces the plan, including every submix strip value (#1053 G1), any edit while a VCA is declared (G3) and a mute change on a track that a `follows_mute` route reads (G2) (`C_ABI_V1_QUALIFICATION.md`, "Value-only track edits on the running plan" and the sections after it). **Sources across a structural transaction.** A committed transaction that replaces the plan does not reset the sources it leaves unchanged. On the C ABI (#1273) such a source keeps its ring, generation and read position across the plan swap; source submissions and seeks address the newest committed session from the commit on, so a host keeps feeding the source contiguously and must not reseek it. PCM is never part of the protocol, so the transaction acknowledges no PCM: PCM already accepted for a source the transaction removed is discarded with its plan, and later submissions for it are refused as `source.id.unknown`. An added source starts at generation 1, frame 0, until the host seeks it; a source whose declaration the transaction changed restarts the same way, in a new ring at generation 1, frame 0, with the PCM accepted for it before the commit discarded with its plan, so the host restarts its feed. -Changing revision or locating transport cancels nothing. The event `AUTOMATION_CANCELED` (`8002`) is **retired** with the command that filled the queue it reported on (#1628; `AUTOMATION_ENQUEUE` is retired, #1627, so no batch is ever accepted and none can be dropped), and a frame under `8002` is refused at decode like any retired ID. A transaction commit reserves only its `SESSION_COMMITTED`; a transport set reserves only its `TRANSPORT_STATE`. `SESSION_COMMITTED`, transport state, and diagnostics are reliable; meter batches and periodic counter snapshots are the only lossy events. A reliable event has a monotonic sequence and queue capacity is reserved before its related commit. +Changing revision cancels nothing, and the transport has no position to locate (#1635 retired the stored transport position). The event `AUTOMATION_CANCELED` (`8002`) is **retired** with the command that filled the queue it reported on (#1628; `AUTOMATION_ENQUEUE` is retired, #1627, so no batch is ever accepted and none can be dropped), and a frame under `8002` is refused at decode like any retired ID. A transaction commit reserves only its `SESSION_COMMITTED`; a transport set reserves only its `TRANSPORT_STATE`. `SESSION_COMMITTED`, transport state, and diagnostics are reliable; meter batches and periodic counter snapshots are the only lossy events. A reliable event has a monotonic sequence and queue capacity is reserved before its related commit. -**Observation subscriptions across a rebuild (#1584).** A host that keeps observation subscriptions (the browser's observe kinds on the engine-wide control plane; the C ABI's own edit follows) holds a table of armed taps beside its monitor overlay and apart from it: host-local, never saved, never in a snapshot, with no revision. A subscription is acknowledged when its record is queued on the newest plan, and the table is written with that acknowledgement. Every plan the host prepares after a rebuild is prepared from the table, so a tap whose effect restarts (a prepared change such as the link mode) is armed in the successor and opens its first window at the successor's first rendered block, as a record applied at that block would; a tap whose effect carries keeps its own window and sequence, and the prepared arm never restarts it. No acknowledged subscription is dropped at a swap. A rebuild that removes the effect (the insert, the strip or the console slot is gone, or the ID now names an effect without that resident tap) ends the binding: the commit queues one reliable `OBSERVATION_ENDED` (`8011`) after the transaction's `SESSION_COMMITTED`, at the committing revision, carrying the stable key of every binding that commit ended, in key order, however many there are. The room for this one event is reserved with the commit's own before anything is prepared or published; if the reliable lane holds unread events that leave no room for both, the transaction is refused as `BACKPRESSURE` with the model, the revision, the table and the plan unchanged, and the same request commits after the lane drains. The number of keys never changes the room needed. An event whose frame would exceed one control frame can never be sent, so that transaction is refused as a resource limit, not as backpressure: the app disarms some of those subscriptions first. Both hosts run the one control plane, so the browser and the C ABI emit the same event for the same transaction. +**Observation subscriptions across a rebuild (#1584).** A host that keeps observation subscriptions (the browser's observe kinds on the engine-wide control plane; the C ABI's own edit follows) holds a table of armed taps beside its monitor overlay and apart from it: host-local, never saved, never in a snapshot, with no revision. A subscription is acknowledged when its record is queued on the newest plan, and the table is written with that acknowledgement. Every plan the host prepares after a rebuild is prepared from the table, so a tap whose effect restarts (a prepared change such as the link mode) is armed in the successor and opens its first window at the successor's first rendered block, as a record applied at that block would; a tap whose effect carries keeps its own window and sequence, and the prepared arm never restarts it. No acknowledged subscription is dropped at a swap. A rebuild that removes the effect (the insert, the strip or the console slot is gone, or the ID now names an effect without that resident tap) ends the binding: the commit queues one reliable `OBSERVATION_ENDED` (`8011`) after the transaction's `SESSION_COMMITTED`, at the committing revision, carrying the stable key of every binding that commit ended, in key order, however many there are. The room for this one event is reserved with the commit's own before anything is prepared or published; if the reliable lane holds unread events that leave no room for both, the transaction is refused as `BACKPRESSURE` with the model, the revision, the table and the plan unchanged, and the same request commits after the lane drains. The number of keys never changes the room needed. The table is capped so that this event always fits (#1676): a session holds at most as many armed taps as one `OBSERVATION_ENDED` frame carries within the control frame limit with every strip and effect ID at its grammar maximum (3 at a 1 KiB frame, 12 at 4 KiB, 51 at the browser's 16 KiB), and a subscription that would exceed it is refused before it is acknowledged, with nothing queued and no state moved (the browser reports it as `RESULT_UNSUPPORTED` with its own reason `observationLimit`, 15, at the subscription that crossed the cap; it is never backpressure, because no render makes room: unsubscribe a tap first). The core admits, checks the queue room and queues the whole batch in one call, `admit_and_push_observation_batch`, so no record is queued without the cap check. A valid removal therefore never meets an unsendable event; the resource-limit refusal for an event over one control frame stays as a defence only. The memory reserved for the event follows the observation slots the session has had, never the frame size. Both hosts run the one control plane, so the browser and the C ABI emit the same event for the same transaction. ## Queues and telemetry diff --git a/docs/CONTROL_PROVIDER_BOUNDARY.md b/docs/CONTROL_PROVIDER_BOUNDARY.md index 228511464..501489ec4 100644 --- a/docs/CONTROL_PROVIDER_BOUNDARY.md +++ b/docs/CONTROL_PROVIDER_BOUNDARY.md @@ -2,7 +2,7 @@ `protocol` is the controller and codec boundary, not a host adapter. A provider offers typed bounded capabilities, parameter pages/state/descriptor lookup, counters, diagnostics, absolute transport get/set, endpoint-local telemetry configuration, and endpoint current sample. It never accepts or returns a raw BTLV body, arbitrary bytes, PCM, a renderer, or a plan. -Providers cap fixture/catalog/page storage at construction and return typed `not found`, `unavailable`, or limit errors. The controller constructs capabilities from effective limits and provider feature flags; it owns canonical encoding and any replay-response bytes. Parameter metadata, state, counters, and diagnostics honor their typed cursor/ID/limit requests. Transport uses an absolute state/position/effective-sample triple only; seek execution and sources remain outside this issue. +Providers cap fixture/catalog/page storage at construction and return typed `not found`, `unavailable`, or limit errors. The controller constructs capabilities from effective limits and provider feature flags; it owns canonical encoding and any replay-response bytes. Parameter metadata, state, counters, and diagnostics honor their typed cursor/ID/limit requests. Transport is an absolute state and the effective sample only (#1635 retired the stored position): the playhead is moved by host seeks, not by the transport, and seek execution and sources remain outside the provider. An in-process caller, local IPC sidecar, browser message/shared-ring adapter, or future C ABI may adapt a complete BTLV frame. The adapter owns framing, authentication, reconnect, multiplexing, and peer lifetime; none is implied by BTLV. WebSocket is only an optional remote boundary and never a render-path dependency. A future ABI uses caller spans and opaque handles under the ownership rules in [the wire specification](CONTROL_BTLV_V1.md#codec-and-ffi-ownership). diff --git a/docs/EFFECT_CONTRACT_V1.md b/docs/EFFECT_CONTRACT_V1.md index c5a1d7017..474d1222e 100644 --- a/docs/EFFECT_CONTRACT_V1.md +++ b/docs/EFFECT_CONTRACT_V1.md @@ -298,10 +298,11 @@ One-pole-99 likewise precomputes `a = exp(ln(0.01)/N)` and `1-a` once, then V1 runtime automation is `Point` spans whose `start_sample` equals the block's first sample, validated off render by `validate_automation_block`; an effect trusts the slice it is given. -`Step`, `Linear` and `Exponential` spans and `AutomationRate::Sample` are descriptor and protocol -vocabulary — `valid_runtime_span` and `automation_segment_value` define their meaning for the -control plane and for the conformance reference mock — whose sample-accurate render-path delivery -is a later protocol capability. Malformed render spans are ignored, counted once, and do not +The `Step`, `Linear` and `Exponential` span kinds (raw values 2 to 4), `automation_segment_value` +and the `Sample` automation rate (raw value 1) are retired (#1636): no producer stages them and no +effect accepts them, so `AutomationSpanKind::from_raw` refuses 2 to 4, `AutomationRate::from_raw` +refuses 1, and the values are never reallocated or renumbered. A parameter is automated at block +rate or not at all. Malformed render spans are ignored, counted once, and do not change the last valid target. Effects may opt into `NativeEffectTargetPreparation` (#807). Their control owner validates @@ -351,7 +352,8 @@ A declared ceiling may be tightened toward measured use, but that moves canonica and is an effect-identity change, never a contract cleanup. Per-lane audio, delay, filter, envelope, smoother, and dual-mono detector state stays in the corresponding lane section. Only shared configuration and an explicitly linked detector may be common. Full reset restores -prepared defaults; discontinuity reset keeps targets but clears histories and active spans. +prepared defaults; discontinuity reset keeps targets, clears histories and snaps every parameter +ramp in flight to its target (runtime automation is `Point` spans only, so no span stays active). ## Stable diagnostics @@ -389,9 +391,8 @@ registry before preparing any effect, a host observes the same refusal as before Stored automation is not judged by effect preparation. `controls::automation_diagnostics` holds each automation to the control row its target names (#1566). A row is live only for an `automatable` -parameter whose `automation_rate` is `Block`: a `None`-rate parameter is prepared, and a `Sample`-rate -one is rebuild-only for correctness (its render-path delivery is the later capability named in -*Parameters and automation*), so a target on either is refused with `control.automation.target`. +parameter whose `automation_rate` is `Block`: a `None`-rate parameter is prepared, so a target on +it is refused with `control.automation.target`. Host preparation and `session-validator` stage 5 report it beside the diagnostics above. ## Evidence contract diff --git a/docs/EFFECT_OBSERVATION_V1.md b/docs/EFFECT_OBSERVATION_V1.md index a35476844..f0ad94215 100644 --- a/docs/EFFECT_OBSERVATION_V1.md +++ b/docs/EFFECT_OBSERVATION_V1.md @@ -130,10 +130,10 @@ an app's `Math.max(0, x ?? 0)` is a **no-op** rather than a silent zeroing. `MisoMeterFrame.trackGrDb` is positional and `trackCount` long, and every entry is finite. `0` deliberately conflates "not reducing" with "no observed effect on this track", because the array is read without null checks. The distinction lives in the `miso.observe.v1` acknowledgement's -subscription map — `{ trackIndex, rack, effectIndex, tapId, frameSlot, windowBlocks }` — which is -the only place that can express it. -`frameSlot` is the strip index (issue #1213): below `trackCount` it indexes `trackGrDb`, and from -there a bus's reading is `submixGrDb[frameSlot - trackCount]`. +subscription map — `{ stripId, rack, effectId, tapId, windowBlocks }` — which is the only place +that can express it. A record names its tap by this stable key (issue #1677) and no position of any +plan. The strip's reading is at its position in the control list's strip order: below `trackCount` +it indexes `trackGrDb`, and from there a bus's reading is `submixGrDb[position - trackCount]`. Several armed taps on one track fold max-magnitude into the one slot, on the control plane. Each tap keeps its own cell. diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index e3583e261..96d1fb79e 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -127,7 +127,10 @@ about 6 dB more than the mute, and over twice that length it clicks as the mute `FINDINGS.md` 9.2). *Matrix coefficients through zero.* A raw matrix coefficient (in `[-1, 1]`) or a send matrix coefficient (any finite value) that crosses zero is a polarity flip of that path on the `pan_ms` ramp; at the defaults `pan_ms` is twice `mute_ms`, so it clicks as the mute does, and a -session whose explicit `pan_ms` is below twice its `mute_ms` gets a louder crossing. +session whose explicit `pan_ms` is below twice its `mute_ms` gets a louder crossing. The same row +lengths are the jump ramps of stored automation (#1058 README A3): every plan holds the three lengths +in one cell, and a live change of them is written there when the session has an automation on a row +of this table (#1639). On the wire the `control_smoothing` record carries `kind` 1 (`default` 1, `explicit` 2), then, for `explicit`, `mute_ms` 2, `fader_ms` 3 and `pan_ms` 4. @@ -250,17 +253,19 @@ model's field registry keeps every ID: track field 7 is now `inserts` (was `dyna field 15 and track field 11, with registries of its own for the slot declaration (`slot` 1, `identity` 2, `quality` 3, `link_mode` 4) and the track entry (`slot` 1, `bypass` 2, `params` 3). -An automation target is a control address (#1566, decision 17 C2): the address `SET_CONTROL` and the -control list use, as a record tagged by `section`, with the keys `owner` (`strip`, `route` or `vca`; +An automation target is a control address (#1566, decision 17 C2): the plain address the control +list uses (fields 1 to 6), as a record tagged by `section`, with the keys `owner` (`strip`, `route` or `vca`; `session` is not a token, because no session row is automatable), `owner_id` (the strip, route or VCA ID), `section` (`own`, `builtins`, `console` or `inserts`), `slot_id` (the console slot or the insert), `control` (a `u32`, the row ID inside the section) and `lane` (`left`, `right` or `both`). `slot_id` is required in a `console` or `inserts` section and refused (`schema.unknown_field`) in `own` and `builtins`; every other key is required. The old keys (`entity_id`, `rack`, `effect_id`, -`parameter_id`, `channel`) are unknown keys. The wire message is the `SET_CONTROL` address message -(fields `owner` 1, `owner_id` 2, `section` 3, `slot_id` 4, `control` 5 and `lane` 6; owner kind -strip 2, route 3, VCA 4 and section inserts 2, builtins 4, console 5, own 6, the retired rack codes -1 and 3 never allocated), and the session wire refuses owner kind session. +`parameter_id`, `channel`) are unknown keys. The wire message is the control list's address +message, `ControlAddress` (fields `owner` 1, `owner_id` 2, `section` 3, `slot_id` 4, `control` 5 and +`lane` 6; owner kind strip 2, route 3, VCA 4 and section inserts 2, builtins 4, console 5, own 6, the +retired rack codes 1 and 3 never allocated), and the session wire refuses owner kind session. A +target carries no expected effect: field 7 belongs to `SET_CONTROL`'s own address message, +`SetControlAddress` (#1678), and a stored target is refused with it. Session validation checks the structure of a target, and nothing about its row: the `strip` owner is a declared track or submix (`reference.missing_entity` at `target.owner_id` otherwise; a VCA ID is diff --git a/fuzz/corpus/complete-schema-manifest.md b/fuzz/corpus/complete-schema-manifest.md index a1ea271b2..110712015 100644 --- a/fuzz/corpus/complete-schema-manifest.md +++ b/fuzz/corpus/complete-schema-manifest.md @@ -3,11 +3,11 @@ The shared `complete_schema_corpus()` fixture has 46 canonical frames: all 11 commands (the session transaction contains all 35 allocated `SessionEditOpcode` values), all 13 successful responses, all 16 registered non-OK statuses (including typed `BACKPRESSURE`), and all six event -schemas. Optional/boundary values are represented by the transaction's nested fixture, optional -transport position, empty valid pages, and the typed backpressure variant. +schemas. Optional/boundary values are represented by the transaction's nested fixture, the optional +transport-event origin, empty valid pages, and the typed backpressure variant. The canonical sequence is FNV-1a-64 over each stable frame label followed by its frame bytes: -`cc9a7bcc502c4936`, pinned once as `COMPLETE_SCHEMA_HASH`. Native mutation, each typed fuzz +`0c7196f86abe6d57`, pinned once as `COMPLETE_SCHEMA_HASH`. Native mutation, each typed fuzz decoder, and scalar/simd128 Wasm execution all consume this same public fixture source. Issue #787 re-pinned this value after the transaction's two same-length source-identity spellings @@ -71,6 +71,15 @@ is added, so the corpus gains `event.observation_ended` (the frame count is 46 a and the capabilities response lists event `0x8011`. #1584 D6a fold: one OBSERVATION_ENDED per commit (root ruling A) re-pinned it from `33e4b1cebcbfd902`: the event carries a repeated key message and the corpus frame carries two keys (the frame count stays 46). +Issue #1678: the `SET_CONTROL` address message gains an optional +seventh field, the native effect the edit was written for, and the transaction appends a `SetControl` +on an insert row that carries it (the frame count stays 46). +#1635 retires the stored transport position, so +`TRANSPORT_SET` field 2, the snapshot's field 2 and the `TRANSPORT_STATE` event's field 3 are refused +and never reallocated (the frame count stays 46); `command.transport_set` carries the state alone. +`retired_code_rows()` gains the `TRANSPORT_SET` field 2 rows, one per flag form. +Batch 13b carries #1678 and #1635 together and re-pins it once from `cc9a7bcc502c4936` (each +alone gave `63ff2e60976e0e21` and `331654b324d076e8`). The value stood at `88a8ee6a6d9e4acc` here until #274. It was correct until `b454b230`, and the two re-pins that followed (`b454b230`, then #241's `04d291dd`) did not reach this file or the diff --git a/hosts/host-web/MUTATIONS.md b/hosts/host-web/MUTATIONS.md index 44a326ac8..3a814e8c4 100644 --- a/hosts/host-web/MUTATIONS.md +++ b/hosts/host-web/MUTATIONS.md @@ -281,10 +281,10 @@ Every row below was applied, the named gate run, the red observed, and the tree | gate | mutation | observed red | |---|---|---| | `test-web-audioworklet.mjs` observation-refusal tests (the shipped defect) | restore `validU32(message.reason) && message.reason <= 9` in `#receive` | `{ tag: 'miso.error.v1', requestId: 250, result: 255 }` — the sticky signature, thrown out of the *first* refused `observe()` instead of settling as a typed `miso.observe.v1` ack. `test-web-audioworklet.sh` runs this mutation on disk and requires the suite red | -| `check-command-reason-vocabulary.py` (the drift class) | add `pub const COMMAND_REASON_FUTURE_TAP: u32 = 12;` after reason 11 (`OBSERVATION_UNBOUND`; reasons 12 to 14, `NOT_SOLOABLE`, `UNKNOWN_ROUTE` and `UNKNOWN_VCA`, were deleted with their value kinds in #1565 and their numbers are not reused; it was 15 after reason 14 before that) in `host-web/src/lib.rs` and nothing else | `host JS table disagrees with the Rust host constants` — a Rust reason bumped without the other five spellings. `test-web-audioworklet.sh` performs this one on a copied file tree, not only in memory | -| `check-command-reason-vocabulary.py --self-test` | twenty in-memory mutations across all six spellings, among them: a renumbered Rust constant; the JS table truncated at `wrongState`; the literal `<= 9` reinstated; the derived bound replaced by `reason <= 14`; the `.d.ts` enum missing or renaming a reason; a generator row dropped or emitting the wrong name for its own constant; the schema gate's list truncated; the render-thread worklet renumbering or renaming the one reason it produces itself | every one refused | -| `check-command-reason-vocabulary.py --self-test` (#151's typing half) | drop `observe()` from `MisoAudioWorkletHost`; drop `windowBlocks` from the declared subscription; add a `channel?` the implementation refuses; drop `frameSlot` from the declared binding; drop `reason` from the declared ack; add a binding field to the implementation the `.d.ts` does not declare | every one refused — the declaration is held to the shipped implementation's actual field sets, not to the issue's sketch | -| `check-parameter-metadata-v1.py --self-test` | truncate `commandReasons` at `wrongState`; rename reason 10; renumber reason 11 to `12` (reason 14 to `15` before #1565 deleted reasons 12 to 14; reason 13 to `14` under issue #1222, reason 12 to `13` under issue #1212) | `command reasons` / `command reason values` — the exact shape of the shipped vocabulary drift | +| `check-command-reason-vocabulary.py` (the drift class) | add `pub const COMMAND_REASON_FUTURE_TAP: u32 = 16;` after reason 11 (`OBSERVATION_UNBOUND`; reasons 12 to 14, `NOT_SOLOABLE`, `UNKNOWN_ROUTE` and `UNKNOWN_VCA`, were deleted with their value kinds in #1565 and their numbers are not reused; #1676 added 15, `OBSERVATION_LIMIT`) in `host-web/src/lib.rs` and nothing else | `host JS table disagrees with the Rust host constants` — a Rust reason bumped without the other five spellings. `test-web-audioworklet.sh` performs this one on a copied file tree, not only in memory | +| `check-command-reason-vocabulary.py --self-test` | thirty-one in-memory mutations in all (the typing mutations of the next row among them) across all six spellings, among them: a renumbered Rust constant; the JS table truncated at `wrongState`; the literal `<= 9` reinstated; the derived bound replaced by `reason <= 14`; the `.d.ts` enum missing or renaming a reason; a generator row dropped or emitting the wrong name for its own constant; the schema gate's list truncated; the render-thread worklet renumbering or renaming the one reason it produces itself; and (#1676) the JS bound accepting a retired `null` entry, the JS table losing or gaining a hole, `observationLimit` moved onto a retired 12 or past a non-retired gap at 16, dropped from the `.d.ts`, the generator or the schema gate's list, or the schema gate's retired set losing 14; plus eight direct cases of the dense-except-retired rule | every one refused | +| `check-command-reason-vocabulary.py --self-test` (#151's typing half) | drop `observe()` from `MisoAudioWorkletHost`; drop `windowBlocks` from the declared subscription; add a `channel?` the implementation refuses; drop `windowBlocks` from the declared binding; drop `reason` from the declared ack; add a binding field to the implementation the `.d.ts` does not declare | every one refused — the declaration is held to the shipped implementation's actual field sets, not to the issue's sketch | +| `check-parameter-metadata-v1.py --self-test` | truncate `commandReasons` at `wrongState`; rename reason 10; renumber reason 11 to `16` (to `12` before #1676 retired 12 to 14 in the gate; reason 14 to `15` before #1565 deleted reasons 12 to 14; reason 13 to `14` under issue #1222, reason 12 to `13` under issue #1212); and (#1676) `observationLimit` on a retired 12, a gap at 16, reason 15 missing, reason 11 missing | `command reasons` / `command reason values` — the exact shape of the shipped vocabulary drift | ## Issue #241 — source introspection follows the declaration @@ -476,7 +476,7 @@ and the mutation was reverted. | `capability-evals.mjs` bus refusal classifier (#1213 D5a, `check-sdk-headless.sh`) | the SDK classifier compares against the track count | the bus's missing insert reports `unknownTrack` | | `capability-evals.mjs` bus solo refusal (#1213 D4, #1212 NIT-2, `check-sdk-headless.sh`) | delete the `solo_safe` check before `set_solo`, artifact rebuilt | the shipped admission reports reason 2, not 12 | | `test-web-audioworklet.mjs` worklet eq-config classifier (#1213 D5a; attempt 2 MINOR-3) | compare `message.trackIndex >= this.trackCount` | the bus's missing insert reports `2` (`unknownTrack`), not `4` | -| `test-web-audioworklet.mjs` bus `observe()` binding (#1213, attempt 2 MAJOR-1) | the host reports `frameSlot: 0` or `Math.min(trackIndex, 1)` for a bus; or the mapping is read as a plain `trackGrDb` index | `frameSlot` is not `T`; the old-contract read is `undefined`, not `submixGrDb[0]` | +| `test-web-audioworklet.mjs` bus `observe()` binding (#1213, attempt 2 MAJOR-1; #1677) | the host drops the submix's ID from the binding, or adds a position of a plan to it | the binding is not exactly `{ stripId, rack, effectId, tapId, windowBlocks }` | ## Issue #1333 — the render-locked allocation counter and the render-thread static rules diff --git a/hosts/host-web/qualification/qualification.js b/hosts/host-web/qualification/qualification.js index 359b76887..09804dc19 100644 --- a/hosts/host-web/qualification/qualification.js +++ b/hosts/host-web/qualification/qualification.js @@ -692,9 +692,9 @@ async function runObservationRun(createHost, sessionDocument, armed) { }), }); const subscription = { - trackIndex: 0, + stripId: "track", rack: 1, - effectIndex: 0, + effectId: "comp", tapId: OBSERVATION_TAP_ID, windowBlocks: Number(LIVE_CONTROL_METER_BLOCKS), armed: true, @@ -723,7 +723,7 @@ async function runObservationRun(createHost, sessionDocument, armed) { subscribeResult: subscribed === null ? null : subscribed.result, subscribeReason: subscribed === null ? null : subscribed.reason, bindings: subscribed === null ? 0 : subscribed.bindings.length, - frameSlot: subscribed?.bindings[0]?.frameSlot ?? -1, + stripId: subscribed?.bindings[0]?.stripId ?? null, windowBlocks: subscribed?.bindings[0]?.windowBlocks ?? -1, frames: frames.length, trackGrDbWidth: frames.length === 0 ? -1 : frames[0].trackGrDb.length, @@ -923,9 +923,9 @@ async function runStagingReadRun(createHost, sessionDocument, streaming) { } else { const subscribed = await host.observe({ subscriptions: [{ - trackIndex: 0, + stripId: "track", rack: 1, - effectIndex: 0, + effectId: "comp", tapId: OBSERVATION_TAP_ID, windowBlocks: Number(LIVE_CONTROL_METER_BLOCKS), armed: true, diff --git a/hosts/host-web/qualification/run.mjs b/hosts/host-web/qualification/run.mjs index 15cc8d0ec..c266ec179 100644 --- a/hosts/host-web/qualification/run.mjs +++ b/hosts/host-web/qualification/run.mjs @@ -293,7 +293,7 @@ function validate(browserName, result) { const observation = result.observation; gate(browserName, "observation", observation?.armed?.meterLeaseResult === 0 && observation?.armed?.subscribeResult === 0 && observation?.armed?.subscribeReason === 0 - && observation?.armed?.bindings === 1 && observation?.armed?.frameSlot === 0 + && observation?.armed?.bindings === 1 && observation?.armed?.stripId === "track" && observation?.armed?.windowBlocks === 2, "the subscription was not acknowledged with a usable map"); gate(browserName, "observation", observation?.armed?.frames >= 1 diff --git a/hosts/host-web/qualification/sdk-response-entry.ts b/hosts/host-web/qualification/sdk-response-entry.ts index b1b46a42a..a948105a0 100644 --- a/hosts/host-web/qualification/sdk-response-entry.ts +++ b/hosts/host-web/qualification/sdk-response-entry.ts @@ -2125,7 +2125,7 @@ async function runSdkObservationQualification(): Promise const map = await browser.observationMap(); const pendingBeforeArm = await browser.readObservations(selections); const subscriptions = await browser.host.observe({ subscriptions: [ - { trackIndex: 0, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 2, armed: true }, + { stripId: "track", rack: 1, effectId: "comp", tapId: 1, windowBlocks: 2, armed: true }, ] }); const pendingAfterArm = await browser.readObservations(selections); for (let block = 0; block < OBSERVATION_FRAMES / 128; block += 1) { diff --git a/hosts/host-web/src/lib.rs b/hosts/host-web/src/lib.rs index aa3845f08..160b29489 100644 --- a/hosts/host-web/src/lib.rs +++ b/hosts/host-web/src/lib.rs @@ -34,8 +34,9 @@ use std::sync::{Arc, Mutex}; use builtins::{MeterMetricSet, MeterSnapshot, MeterTap}; use builtins_compiler::MeterConsumer; use control_plane::{ - CompileFailure, ControlAdapter, ControlLimits, PlanQueries, PlanResources, PlanState, - SessionState, SourceFailure, + CompileFailure, ControlAdapter, ControlLimits, ObservationChange, ObservationKeyError, + ObservationKeyRef, ObservationRefusal, PlanQueries, PlanResources, PlanState, SessionState, + SourceFailure, }; use effect_contract::{ObservationCost, ObservationDescriptor}; use engine::realtime::{ @@ -514,8 +515,12 @@ pub const LIVE_RESPONSE_SECTION_BYTES: u32 = size_of::() #[allow(missing_docs)] pub const LIVE_RESPONSE_RESULT_BYTES: u32 = size_of::() as u32; -/// Exact byte size of one staged `miso.command.v1` record. -pub const COMMAND_RECORD_BYTES: u32 = 48; +/// Exact byte size of one staged `miso.command.v1` record (#1677): the record names its tap by +/// stable key, so it carries the strip's and the effect's IDs. +pub const COMMAND_RECORD_BYTES: u32 = 272; +/// Width in bytes of each ID field of a staged record. An ID is 1 to 127 bytes +/// (`session::STABLE_ID_MAXIMUM_BYTES`), zero padded to this width. +pub const RECORD_ID_FIELD_BYTES: u32 = 128; /// Largest number of records one `miso.command.v1` submission may stage. /// /// The staging buffer is allocated at preparation for exactly this many records, so a submission @@ -746,22 +751,20 @@ pub struct ObservationReadValues { /// Arm one declared observation tap of one effect instance (issue #143 D3). /// -/// `parameter_id` carries the effect-local `tap_id`, `smoothing_samples` carries the window length -/// in render blocks (`0` = the plan's default), and every value word must be `0.0`: a subscription -/// changes what is read, never what is rendered, so a nonzero value would be a caller mistake -/// rather than a meaningful field. +/// The record names the tap by its stable key: the strip ID, the rack, the console slot's or +/// insert's ID and the effect-local `tap_id`, the same key `OBSERVATION_ENDED` uses (#1677). +/// `window_blocks` carries the window length in render blocks (`0` = the plan's default). A +/// subscription changes what is read, never what is rendered. pub const COMMAND_OBSERVE_SUBSCRIBE: u32 = 7; /// Disarm one declared observation tap of one effect instance (issue #143 D3). pub const COMMAND_OBSERVE_UNSUBSCRIBE: u32 = 8; -/// The `rack` of an effect-addressed record: one of the track's inserts (decision 12, #1096). +/// The `rack` of an effect-addressed record: one of the strip's inserts (decision 12, #1096). /// -/// It keeps the code the retired `dynamic` rack had. `effect_index` is the insert's position in -/// the track's `inserts`. +/// It keeps the code the retired `dynamic` rack had. The record's effect ID is the insert's ID. pub const RACK_INSERTS: u8 = 1; /// The `rack` of an effect-addressed record: a session console slot (decision 12, #1096). /// -/// Appended. `effect_index` is the slot's position in the session's slot order, `pre_insert` then -/// `post_insert`, which is its index in the track's `console` entries. +/// Appended. The record's effect ID is the console slot's ID. /// /// The retired `simd1` and `simd2` codes, `0` and `2`, are refused with /// [`COMMAND_REASON_UNKNOWN_RACK`] (or the observation and configuration paths' invalid-argument @@ -821,12 +824,13 @@ fn dense_effect_slot(base: u32, counts: [u32; 3], address: LiveEffectAddress) -> /// canonical ID order. Each segment is sorted but their concatenation is not -- a submix ID may /// sort before a track ID -- so the two segments are searched apart; a single binary search over /// the concatenation would silently miss such a submix. -fn strip_index(tracks: &[Box], submixes: &[Box], id: &str) -> Option { - if let Ok(track) = tracks.binary_search_by(|track| track.as_ref().cmp(id)) { +fn strip_index(tracks: &[Box], submixes: &[Box], id: &[u8]) -> Option { + // `str` orders by its bytes, so a byte-wise search is the search the canonical order allows. + if let Ok(track) = tracks.binary_search_by(|track| track.as_bytes().cmp(id)) { return Some(track); } let submix = submixes - .binary_search_by(|submix| submix.as_ref().cmp(id)) + .binary_search_by(|submix| submix.as_bytes().cmp(id)) .ok()?; tracks.len().checked_add(submix) } @@ -836,12 +840,14 @@ pub const COMMAND_REASON_NONE: u32 = 0; /// A record's fixed shape is wrong: an unknown kind, a nonzero reserved word, or a non-finite value; /// or the submission names one observation tap more than once (#1345 D2). pub const COMMAND_REASON_MALFORMED: u32 = 1; -/// `track_index` is not a track of the compiled session. +/// The record's strip ID is not a track or submix of the plan the record is admitted against +/// (#1677). The code keeps its number and its name from the index-addressed record. pub const COMMAND_REASON_UNKNOWN_TRACK: u32 = 2; /// `rack` is not an effect rack: an effect-addressed kind names [`RACK_INSERTS`] or /// [`RACK_CONSOLE`]. The retired `simd1` (`0`) and `simd2` (`2`) codes land here (issue #1096). pub const COMMAND_REASON_UNKNOWN_RACK: u32 = 3; -/// `effect_index` is not an effect of the addressed rack. +/// The record's effect ID is not a console slot or insert (per `rack`) of the addressed strip on +/// the plan the record is admitted against (#1677). pub const COMMAND_REASON_UNKNOWN_EFFECT: u32 = 4; /// `parameter_id` is not a parameter of the addressed effect. No record raises it since #1565A /// deleted the value kinds; the number stays declared and is never reallocated. @@ -857,7 +863,9 @@ pub const COMMAND_REASON_DOMAIN: u32 = 6; /// This is not "malformed" and it is not "unknown target": the record is well formed and the tap /// exists, and this session has no post-preparation write path for it. pub const COMMAND_REASON_UNSUPPORTED_KIND: u32 = 7; -/// A bounded control queue had no room for the submission; nothing was admitted. +/// A bounded control queue had no room for the submission; nothing was admitted. Retryable: the +/// queue drains at the next render. Never the answer to the cap on armed taps, which is +/// [`COMMAND_REASON_OBSERVATION_LIMIT`]. pub const COMMAND_REASON_BACKPRESSURE: u32 = 8; /// The host is not `STATE_READY`. pub const COMMAND_REASON_WRONG_STATE: u32 = 9; @@ -874,6 +882,19 @@ pub const COMMAND_REASON_UNKNOWN_TAP: u32 = 10; /// staging, and refuses every submission `COMMAND_REASON_UNSUPPORTED_KIND` before it reads a /// record (#1565C); this reason is only reachable with capacity bound. pub const COMMAND_REASON_OBSERVATION_UNBOUND: u32 = 11; +// Reasons 12-14 (`notSoloable`, `unknownRoute`, `unknownVca`) belonged to the value kinds #1565A +// deleted. They are retired and never reallocated, so the vocabulary is dense from 0 except for +// them, and the next reason is 15. +/// The subscription would leave more taps armed than one `OBSERVATION_ENDED` event can name in one +/// control frame (#1676), so the engine could not tell the app honestly when a later edit ends +/// them all. The record named is the arm that crossed the cap, after every unsubscribe of the same +/// submission is counted; nothing was admitted. +/// +/// Not retryable as sent, and deliberately **not** `BACKPRESSURE`: no render makes room. The +/// submission is admitted only after the app unsubscribes another tap. The cap is the control +/// plane's (`observation_cap`, 51 taps at the browser's 16 KiB frame), so the result is +/// `RESULT_UNSUPPORTED`, as for [`COMMAND_REASON_OBSERVATION_UNBOUND`]. +pub const COMMAND_REASON_OBSERVATION_LIMIT: u32 = 15; /// Default meter window in render blocks: ~31 frames per second at 48 kHz with a 128-frame quantum. pub const DEFAULT_METER_BLOCKS: u32 = 12; @@ -1346,6 +1367,11 @@ struct PlanShape { /// It is the inverse of that field, built in the same preparation loop, so the swap block's /// carry record, which names readers (#1327 D3), reaches the per-slot tap state below (#1381b). reader_slots: Box<[u32]>, + /// The dense effect slot of each producer of the plan, in the control plane's producer order: + /// entry `p` is the slot whose [`EffectInstance::producer`] is `p`. The inverse of that field, + /// built in the same preparation loop, so the key the control plane resolved to a producer + /// position reaches the per-slot tap state by an index, not a search (#1677). + producer_slots: Box<[u32]>, /// Armed-tap bitmask per effect slot, maintained at **admission**. /// /// A conflating cell holds its last window forever -- that is what makes it wait-free -- so @@ -1806,11 +1832,11 @@ impl ReadyOwnership { /// Run `admit` with the tables of the newest committed plan in place of the rendering plan's /// (#1290a, batch 10 review): the rendering plan's while no replacement is waiting for its /// block, else the waiting replacement's, the plan whose producers the control plane lists - /// (`effect_producers`). An observation record addresses an - /// effect by the tables and reaches it through the producers, so both must be one plan's: the - /// newest, as a live value is, so a submission between a rebuild's commit and its adoption is - /// admitted against the successor and takes effect on it, and the browser answers nothing the - /// C ABI would not. + /// (`effect_producers`). An observation record names its tap by stable key; the tables resolve + /// the key to an effect (#1677) and the producers reach its queue, so both must be one plan's: + /// the newest, as a live value is, so a submission between a rebuild's commit and its adoption + /// is admitted against the successor and takes effect on it, and the browser answers nothing + /// the C ABI would not. /// /// The tables are exchanged by pointer and exchanged back before this returns, so nothing /// allocates and the rendering plan's tables are the host's again for the next render call. @@ -1891,10 +1917,21 @@ impl ReadyOwnership { value } - /// Strips these controls address: the tracks, then the submixes (issue #1213 D1). An - /// observation record's `track_index` is a strip index, so a bus is addressed as a track is. - fn strip_count(&self) -> usize { - self.shape.tracks.len() + self.shape.submixes.len() + /// The dense effect index of the console slot or insert `effect_id` of one strip on this plan + /// (#1677): the one place a stable effect ID becomes a position, so an observation record and + /// a selected read cannot disagree. `None` when the strip has no such slot or insert. + fn effect_by_id(&self, strip: usize, rack: LiveEffectRack, effect_id: &[u8]) -> Option { + let [pre_insert, inserts, post_insert] = *self.shape.rack_effects.get(strip)?; + // A console slot is searched over both sections, in slot order; an insert over the inserts. + let count = match rack { + LiveEffectRack::Console => pre_insert.saturating_add(post_insert), + LiveEffectRack::Inserts => inserts, + }; + (0..count).find_map(|index| { + let effect = self.effect_slot(strip, LiveEffectAddress { rack, index })?; + let instance = self.shape.effect_instances.get(effect)?.as_ref()?; + (instance.effect_id.as_bytes() == effect_id).then_some(effect) + }) } /// The dense index of one addressed effect instance in `effect_instances`. @@ -1914,35 +1951,20 @@ impl ReadyOwnership { /// The position, in the control plane's producer list, of the effect at dense slot `effect` /// (#1381a): the one way the observation records reach an effect's observation queue (ruling - /// C9). - fn effect_producer_position(&self, effect: usize) -> Option { - let position = self.shape.effect_instances.get(effect)?.as_ref()?.producer; + /// C9). A free function of the shape, so the admission can read it while it lends the control + /// plane mutably. + fn effect_producer_position(shape: &PlanShape, effect: usize) -> Option { + let position = shape.effect_instances.get(effect)?.as_ref()?.producer; Some(position as usize) } - /// The free room of one effect's observation queue (one slot per declared tap, #1345 D2). - fn queue_available(&self, effect: usize) -> Option { - let position = self.effect_producer_position(effect)?; - let producer = self.control.effect_producers().get(position)?; - u32::try_from(producer.observe_available_capacity()).ok() - } - - /// Queue one admitted observation subscription on its effect. `Err` only on a full queue, - /// which the free-room pass has already ruled out. - fn push_observe(&mut self, staged: StagedObserve, applied_at_sample: u64) -> Result<(), ()> { + /// Record one observation subscription the control plane has just queued on its effect + /// (#1676 attempt 2: the queue push itself is the core's + /// [`SessionState::admit_and_push_observation_batch`], which admits the whole batch first). + fn record_observe(&mut self, staged: StagedObserve, applied_at_sample: u64) { let effect = staged.effect as usize; // Issue #143: the armed set is control-plane state and is updated here, where the record // is admitted, so it can never disagree with what the render side was told. - let position = self.effect_producer_position(effect).ok_or(())?; - self.control - .push_effect_observe( - position, - staged.tap_index, - staged.armed, - staged.window_blocks, - ) - .ok_or(())? - .map_err(|_| ())?; if let Some(mask) = self.shape.observation_armed.get_mut(effect) && staged.tap_index < u32::BITS { @@ -1961,7 +1983,6 @@ impl ReadyOwnership { *commanded |= bit; } } - Ok(()) } } @@ -4045,43 +4066,41 @@ struct CommandRejection { index: u32, } -/// One decoded, still-unapplied `miso.command.v1` record (issue #137 D1). +/// One decoded, still-unapplied `miso.command.v1` record (issue #137 D1, #1677). /// -/// # The frozen 48-byte little-endian layout +/// # The frozen 272-byte little-endian layout /// /// | offset | width | field | /// |---|---|---| /// | 0 | u8 | `kind`: [`COMMAND_OBSERVE_SUBSCRIBE`] or [`COMMAND_OBSERVE_UNSUBSCRIBE`] | /// | 1 | u8 | `rack`: `1` inserts, `3` console; `0` and `2` retired | -/// | 2 | u8 | `channel`: `255` (not applicable) | -/// | 3 | u8 | required zero | -/// | 4 | u32 | `track_index` into the canonical normalized strip order | -/// | 8 | u32 | `effect_index`: a console slot's index in slot order, or an insert's index | -/// | 12 | u32 | `parameter_id`: the effect-local `tap_id` | -/// | 16 | u32 | `smoothing_samples`: the window length in render blocks (`0` = the plan's default) | -/// | 20 | u32 | required zero | -/// | 24..40 | 4 x f32 | `value0..value3`, all `0.0` | -/// | 40 | u64 | required zero | +/// | 2 | u8 | `strip_id_bytes`: the length of the strip ID, `1..=127` | +/// | 3 | u8 | `effect_id_bytes`: the length of the console slot's or insert's ID, `1..=127` | +/// | 4 | u32 | `tap_id`: the effect-local tap ID | +/// | 8 | u32 | `window_blocks`: the window length in render blocks (`0` = the plan's default) | +/// | 12 | u32 | required zero | +/// | 16 | 128 bytes | `strip_id`: the strip's ID, zero padded | +/// | 144 | 128 bytes | `effect_id`: the console slot's or insert's ID, zero padded | /// -/// The record keeps the 48-byte layout the value kinds shared (kinds 1-6 and 9-17 are deleted, -/// #1565A, and their numbers are never reallocated), so the staging, the report and the ABI -/// version did not move. There is no string on this path. The identity mapping a caller needs -- -/// strip index to ID, effect index to effect ID, tap ID to descriptor -- is the session map the -/// ready message carries and the build-time metadata, never a per-command lookup. +/// The record names its tap by the stable key `OBSERVATION_ENDED` uses (strip, section, slot or +/// insert ID, tap ID). No index of any plan is in it, so the core resolves the key on the plan it +/// admits the record against, and a rebuild between a client's last read and the record cannot make +/// it arm another effect's tap. Kinds 1-6 and 9-17 are deleted (#1565A) and their numbers are never +/// reallocated. There is no string parsing on this path: the IDs are compared as bytes with the +/// plan's own. #[derive(Clone, Copy)] -struct CommandRecord { +struct CommandRecord<'a> { kind: u32, rack: u8, - channel: u8, - track_index: u32, - effect_index: u32, - parameter_id: u32, - smoothing_samples: u32, - values: [f32; 4], + tap_id: u32, + window_blocks: u32, + strip_id: &'a [u8], + effect_id: &'a [u8], } -impl CommandRecord { - fn decode(bytes: &[u8]) -> Result { +impl<'a> CommandRecord<'a> { + fn decode(bytes: &'a [u8]) -> Result { + const ID: usize = RECORD_ID_FIELD_BYTES as usize; if bytes.len() != COMMAND_RECORD_BYTES as usize { return Err(COMMAND_REASON_MALFORMED); } @@ -4093,7 +4112,7 @@ impl CommandRecord { bytes[offset + 3], ]) }; - if bytes[3] != 0 || word(20) != 0 || bytes[40..48].iter().any(|value| *value != 0) { + if word(12) != 0 { return Err(COMMAND_REASON_MALFORMED); } let kind = u32::from(bytes[0]); @@ -4103,71 +4122,52 @@ impl CommandRecord { ) { return Err(COMMAND_REASON_MALFORMED); } - let mut values = [0.0_f32; 4]; - for (index, value) in values.iter_mut().enumerate() { - *value = f32::from_le_bytes([ - bytes[24 + index * 4], - bytes[25 + index * 4], - bytes[26 + index * 4], - bytes[27 + index * 4], - ]); - if !value.is_finite() { + // An ID field holds its bytes and then zeros: a length of zero, a length past the field, + // or a byte after the length, is a record that does not say what it names. A stable ID is + // 1..=127 bytes, so an empty one names nothing. + let id = |length: u8, offset: usize| -> Result<&'a [u8], u32> { + let length = usize::from(length); + let field = &bytes[offset..offset + ID]; + if length == 0 || length >= ID || field[length..].iter().any(|byte| *byte != 0) { return Err(COMMAND_REASON_MALFORMED); } - } + Ok(&field[..length]) + }; Ok(Self { kind, rack: bytes[1], - channel: bytes[2], - track_index: word(4), - effect_index: word(8), - parameter_id: word(12), - smoothing_samples: word(16), - values, + tap_id: word(4), + window_blocks: word(8), + strip_id: id(bytes[2], 16)?, + effect_id: id(bytes[3], 16 + ID)?, }) } - /// Lower one observation subscribe/unsubscribe against the addressed effect's declared menu. - /// - /// `parameter_id` carries the effect-local `tap_id` and is translated to the `tap_index` the - /// render side arms — an admission-time lookup, off the render thread. A tap the descriptor - /// does not declare is [`COMMAND_REASON_UNKNOWN_TAP`] and never - /// [`COMMAND_REASON_UNKNOWN_PARAMETER`]: they are different namespaces on one effect, and a - /// caller that confuses them learns which one it got wrong. - fn into_observe_record( - self, - descriptor: &'static effect_contract::EffectDescriptor, - observed: bool, - ) -> Result<(u32, bool, u32), u32> { - if self.channel != 255 || self.values.iter().any(|value| *value != 0.0) { - return Err(COMMAND_REASON_MALFORMED); - } - if self.parameter_id == 0 { - return Err(COMMAND_REASON_UNKNOWN_TAP); - } - let Some((tap_index, tap)) = descriptor - .observations - .iter() - .enumerate() - .find(|(_, tap)| tap.id.0 == self.parameter_id) - else { - return Err(COMMAND_REASON_UNKNOWN_TAP); + /// The stable key this record names, for the control plane to resolve on the plan it admits + /// against (#1677). `rack` is the one part of the key the record spells as a code: an unknown + /// code, the retired `simd1` and `simd2` among them, is [`COMMAND_REASON_UNKNOWN_RACK`]. + fn key(&self) -> Result, u32> { + let rack = match self.rack { + RACK_INSERTS => LiveEffectRack::Inserts, + RACK_CONSOLE => LiveEffectRack::Console, + _ => return Err(COMMAND_REASON_UNKNOWN_RACK), }; - // A computed tap is declared, validated and refused: V1 ships no implementation for one, - // and saying so is better than binding a lane that would never publish. - if !matches!(tap.cost, effect_contract::ObservationCost::Resident) { - return Err(COMMAND_REASON_UNSUPPORTED_KIND); - } - // The tap exists and the address is right; this *session* bound no lane to arm. - if !observed { - return Err(COMMAND_REASON_OBSERVATION_UNBOUND); - } - let tap_index = u32::try_from(tap_index).map_err(|_| COMMAND_REASON_MALFORMED)?; - Ok(( - tap_index, - self.kind == COMMAND_OBSERVE_SUBSCRIBE, - self.smoothing_samples, - )) + Ok(ObservationKeyRef { + strip_id: self.strip_id, + rack, + effect_id: self.effect_id, + tap_id: self.tap_id, + }) + } +} + +/// The reason a key the control plane could not resolve is refused with (#1677). +const fn key_refusal(error: ObservationKeyError) -> u32 { + match error { + ObservationKeyError::UnknownStrip => COMMAND_REASON_UNKNOWN_TRACK, + ObservationKeyError::UnknownEffect => COMMAND_REASON_UNKNOWN_EFFECT, + ObservationKeyError::UnknownTap => COMMAND_REASON_UNKNOWN_TAP, + ObservationKeyError::ComputedTap => COMMAND_REASON_UNSUPPORTED_KIND, } } @@ -4186,17 +4186,17 @@ fn admit_commands( applied_at_sample: u64, ) -> Result<(), CommandRejection> { let record_bytes = COMMAND_RECORD_BYTES as usize; - // The index word is a strip index, tracks first, then submixes (issue #1213 D1). - let strip_count = ready.strip_count(); ready.shape.command_wanted.fill(0); ready.shape.observe_named.fill(0); let refuse = |reason: u32, index: usize| CommandRejection { result: match reason { // `ObservationUnbound` is "this preparation cannot deliver it", which is exactly what // `RESULT_UNSUPPORTED` means; `UnknownTap` is a bad address, like every other unknown. - COMMAND_REASON_UNSUPPORTED_KIND | COMMAND_REASON_OBSERVATION_UNBOUND => { - RESULT_UNSUPPORTED - } + // `ObservationLimit` is the same answer for the configuration: this session's control + // frame cannot end one more tap honestly, and only an unsubscribe changes that. + COMMAND_REASON_UNSUPPORTED_KIND + | COMMAND_REASON_OBSERVATION_UNBOUND + | COMMAND_REASON_OBSERVATION_LIMIT => RESULT_UNSUPPORTED, COMMAND_REASON_BACKPRESSURE => RESULT_BACKPRESSURE, _ => RESULT_INVALID_ARGUMENT, }, @@ -4207,34 +4207,34 @@ fn admit_commands( for index in 0..count { let record = &bytes[index * record_bytes..(index + 1) * record_bytes]; let command = CommandRecord::decode(record).map_err(|reason| refuse(reason, index))?; - let track = command.track_index as usize; - if track >= strip_count { - return Err(refuse(COMMAND_REASON_UNKNOWN_TRACK, index)); - } - // `inserts` (1) and `console` (3); the retired `simd1` (0) and `simd2` (2) are refused here - // and never reinterpreted (decision 12, issue #1096). - let Some(address) = live_effect_address(u32::from(command.rack), command.effect_index) - else { - return Err(refuse(COMMAND_REASON_UNKNOWN_RACK, index)); - }; - if ready.shape.rack_effects.get(track).is_none() { - return Err(refuse(COMMAND_REASON_UNKNOWN_TRACK, index)); - } - let Some(effect) = ready.effect_slot(track, address) else { - return Err(refuse(COMMAND_REASON_UNKNOWN_EFFECT, index)); - }; - let Some((descriptor, observed)) = ready + // The record names its tap by stable key, and the control plane resolves it on the newest + // committed plan, the one this submission is admitted against (#1677). Every refusal is + // here, before the room pass and any write. + let key = command.key().map_err(|reason| refuse(reason, index))?; + let resolved = ready + .control + .resolve_observation_key(&key) + .map_err(|error| refuse(key_refusal(error), index))?; + let tap_index = resolved.tap_index; + let armed = command.kind == COMMAND_OBSERVE_SUBSCRIBE; + let window_blocks = command.window_blocks; + // The tables of the same plan reach the effect's per-slot state from its producer. + let Some((effect, observed)) = ready .shape - .effect_instances - .get(effect) - .and_then(Option::as_ref) - .map(|instance| (instance.descriptor, instance.reader.is_some())) + .producer_slots + .get(resolved.producer) + .map(|slot| *slot as usize) + .and_then(|slot| { + let instance = ready.shape.effect_instances.get(slot)?.as_ref()?; + Some((slot, instance.reader.is_some())) + }) else { return Err(refuse(COMMAND_REASON_UNSUPPORTED_KIND, index)); }; - let (tap_index, armed, window_blocks) = command - .into_observe_record(descriptor, observed) - .map_err(|reason| refuse(reason, index))?; + // The tap exists and the key is right; this *session* bound no lane to arm. + if !observed { + return Err(refuse(COMMAND_REASON_OBSERVATION_UNBOUND, index)); + } // One submission names each tap once: a record overwrites its tap's whole state and the // submission drains in one block, so the second record would be a supersession the engine // must not make (D15-2: Observe records are never superseded) and would not fit the tap's @@ -4268,30 +4268,70 @@ fn admit_commands( }; staged_count += 1; } - // The room pass: an effect's observation queue holds one subscription per declared tap, and - // the subscriptions this submission queues must fit it. + // The in-flight counter of an effect counts its records the render side has not drained. The + // queue's room bounds it far below `u32::MAX`, so this refusal is a defence that never fires; + // it runs before the core is lent the batch because nothing may be refused after a push. for entry in 0..staged_count { let staged = ready.command_decoded[entry]; let effect = staged.effect as usize; - let needed = ready.shape.command_wanted[effect]; - let Some(available) = ready.queue_available(effect) else { - return Err(refuse( - COMMAND_REASON_UNSUPPORTED_KIND, - staged.original_wire_index as usize, - )); - }; - if needed > available || ready.shape.in_flight[effect].checked_add(needed).is_none() { + if ready.shape.in_flight[effect] + .checked_add(ready.shape.command_wanted[effect]) + .is_none() + { return Err(refuse( COMMAND_REASON_BACKPRESSURE, staged.original_wire_index as usize, )); } } + // The cap, the room and the push are one core call (#1676): the control plane judges the whole + // submission by the taps it leaves armed, then by the room of every effect's observation queue, + // and only then queues it. A subscription past what one `OBSERVATION_ENDED` event can name in a + // control frame is refused there, before any record is queued and so before any is + // acknowledged, and the adapter reports it as `observationLimit` at the record that crossed it. + // A change's index in the batch is its staged index, and the staged index names the record's + // wire index. + let staged = &ready.command_decoded[..staged_count]; + let shape = &ready.shape; + let admitted = ready + .control + .admit_and_push_observation_batch(staged.iter().map(|staged| { + ObservationChange { + // Pass one resolved every record to a producer; a position that vanished names no + // producer and is refused as such. + position: ReadyOwnership::effect_producer_position(shape, staged.effect as usize) + .unwrap_or(usize::MAX), + tap_index: staged.tap_index, + armed: staged.armed, + window_blocks: staged.window_blocks, + } + })); + if let Err(refusal) = admitted { + let (reason, index) = match refusal { + ObservationRefusal::Limit(limit) => (COMMAND_REASON_OBSERVATION_LIMIT, limit.index), + ObservationRefusal::Full { index } => (COMMAND_REASON_BACKPRESSURE, index), + ObservationRefusal::NoProducer { index } => (COMMAND_REASON_UNSUPPORTED_KIND, index), + // Pass one already refuses a second naming of a tap as malformed at that record; the + // core's own check gives the same answer, so the browser's outcome is one. + ObservationRefusal::Duplicate { index } => (COMMAND_REASON_MALFORMED, index), + // Pass one resolved the tap from the effect's menu and refused an effect with no + // reader as `observationUnbound`; the core's own slot check gives the same answer. + ObservationRefusal::Unbound { index } => (COMMAND_REASON_OBSERVATION_UNBOUND, index), + }; + let wire = staged + .get(index) + .map_or(0, |staged| staged.original_wire_index as usize); + return Err(refuse(reason, wire)); + } + debug_assert!( + ready.control.observation_armed() <= ready.control.observation_cap(), + "a browser admission left {} taps armed past the cap {}", + ready.control.observation_armed(), + ready.control.observation_cap() + ); for entry in 0..staged_count { let staged = ready.command_decoded[entry]; - ready - .push_observe(staged, applied_at_sample) - .expect("the free-room pass checked this record's room"); + ready.record_observe(staged, applied_at_sample); ready.shape.in_flight[staged.effect as usize] += 1; ready.has_in_flight_commands = true; } @@ -4358,43 +4398,12 @@ fn resolve_observation( let track = strip_index( &ready.shape.tracks, &ready.shape.submixes, - selection.track_id, + selection.track_id.as_bytes(), ) .ok_or(ObservationReadError::InvalidSelection)?; - let [pre_insert, inserts, post_insert] = ready - .shape - .rack_effects - .get(track) - .copied() + let effect = ready + .effect_by_id(track, selection.rack, selection.effect_slot_id.as_bytes()) .ok_or(ObservationReadError::InvalidSelection)?; - // A console slot is searched over both sections, in slot order; an insert over the inserts. - let effect_count = match selection.rack { - LiveEffectRack::Console => pre_insert.saturating_add(post_insert), - LiveEffectRack::Inserts => inserts, - }; - let mut addressed = None; - for index in 0..effect_count { - let address = LiveEffectAddress { - rack: selection.rack, - index, - }; - let Some(effect) = ready.effect_slot(track, address) else { - return Err(ObservationReadError::InvalidSelection); - }; - let Some(observed) = ready - .shape - .effect_instances - .get(effect) - .and_then(Option::as_ref) - else { - continue; - }; - if observed.effect_id.as_ref() == selection.effect_slot_id { - addressed = Some(effect); - break; - } - } - let effect = addressed.ok_or(ObservationReadError::InvalidSelection)?; let handle = ready .shape .effect_instances @@ -5739,6 +5748,11 @@ fn build_plan_shape( .try_reserve_exact(total_effects as usize) .map_err(|_| "web.resource.allocation")?; effect_instances.resize_with(total_effects as usize, || None); + let mut producer_slots: Vec = Vec::new(); + producer_slots + .try_reserve_exact(producers.len()) + .map_err(|_| "web.resource.allocation")?; + producer_slots.resize(producers.len(), u32::MAX); for (position, producer) in producers.iter().enumerate() { let (track_id, address, effect_id, descriptor) = ( &producer.track_id, @@ -5746,7 +5760,7 @@ fn build_plan_shape( &producer.effect_id, producer.descriptor, ); - let Some(strip) = strip_index(&tracks, &submixes, track_id) else { + let Some(strip) = strip_index(&tracks, &submixes, track_id.as_bytes()) else { return Err("web.live_controls.effects"); }; let Some(entry) = dense_effect_slot(effect_base[strip], rack_effects[strip], *address) @@ -5757,6 +5771,11 @@ fn build_plan_shape( if entry.is_some() { return Err("web.live_controls.effects"); } + // The slot the entry sits at: `dense_effect_slot` again, since `entry` borrows the table. + let slot = dense_effect_slot(effect_base[strip], rack_effects[strip], *address) + .and_then(|slot| u32::try_from(slot).ok()) + .ok_or("web.live_controls.effects")?; + producer_slots[position] = slot; *entry = Some(EffectInstance { producer: u32::try_from(position).map_err(|_| "web.live_controls.effects")?, reader: None, @@ -5791,7 +5810,7 @@ fn build_plan_shape( meter_header.master_track_plus_one = master_track.map_or(0, |track| track.saturating_add(1)); meter_header.frame_pointer = address_word(meter_frame.as_ptr()); for (reader, handle) in observations.iter().enumerate() { - let Some(strip) = strip_index(&tracks, &submixes, &handle.track_id) else { + let Some(strip) = strip_index(&tracks, &submixes, handle.track_id.as_bytes()) else { return Err("web.live_controls.observation"); }; let Some(slot) = dense_effect_slot(effect_base[strip], rack_effects[strip], handle.address) @@ -5819,7 +5838,7 @@ fn build_plan_shape( observation_arm_samples[slot] = arm_samples.into_boxed_slice(); // #1584 D6b: the taps the host had armed are the ones host-core armed in this plan's // lanes, in the descriptor's tap order (the bit is the effect-local tap index, as - // `push_observe` sets it); host-core made the match, so it is copied, not repeated. + // `record_observe` sets it); host-core made the match, so it is copied, not repeated. if let Some(word) = observation_armed.get_mut(slot) { *word = handle.armed_taps; } @@ -5834,6 +5853,7 @@ fn build_plan_shape( effect_instances: effect_instances.into_boxed_slice(), observation_tracks: observation_tracks.into_boxed_slice(), reader_slots: reader_slots.into_boxed_slice(), + producer_slots: producer_slots.into_boxed_slice(), observation_armed, observation_arm_samples: observation_arm_samples.into_boxed_slice(), observation_commanded: zero_u32(total_effects as usize)?, @@ -6026,6 +6046,12 @@ impl PlanShape { u64::try_from(self.reader_slots.len()) .ok()? .checked_mul(size_of::() as u64)?, + )? + // #1677: the producer-to-instance table is retained beside the reader table. + .checked_add( + u64::try_from(self.producer_slots.len()) + .ok()? + .checked_mul(size_of::() as u64)?, )?; let pending_bytes = u64::try_from(self.meter_pending.len()) .ok()? @@ -6291,8 +6317,8 @@ fn compile_ready( report.largest_named_allocation_bytes = report .largest_named_allocation_bytes .max(report.largest_bridge_allocation_bytes); - // The decoded observation staging is a separate retained allocation from the public 48-byte - // wire staging row; charge the full actual array before the final aggregate budget check. + // The decoded observation staging is a separate retained allocation from the public wire + // staging row; charge the full actual array before the final aggregate budget check. let decoded_count = if commands_enabled { MAXIMUM_COMMAND_RECORDS as usize } else { diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 7cb4145fd..b73b80ba7 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -272,7 +272,8 @@ fn frozen_layouts_and_values_are_exact() { // already sets it to zero, so the layout and every existing caller stand. assert_eq!(size_of::(), 48); assert_eq!(COMMAND_REPORT_BYTES, 48); - assert_eq!(COMMAND_RECORD_BYTES, 48); + assert_eq!(COMMAND_RECORD_BYTES, 272); + assert_eq!(RECORD_ID_FIELD_BYTES, 128); assert_eq!(MAXIMUM_COMMAND_RECORDS, 256); assert_eq!(DEFAULT_METER_BLOCKS, 12); // Kinds 1-6 and 9-17 are deleted (#1565A) and their numbers are never reallocated. @@ -1683,6 +1684,7 @@ fn set_control_timeline_frames() -> Vec> { let eq = || Section::Inserts(session::StableId::parse("eq").expect("effect ID")); let set = |address: ControlAddress, value: f32, ramp_samples: Option| SessionEdit::SetControl { + expected_effect: None, address, value, ramp_samples, @@ -1937,21 +1939,22 @@ mod serde_pin { } } -/// Stage one `miso.command.v1` record into the fixed command buffer at `index`. +/// Stage one `miso.command.v1` record into the fixed command buffer at `index`, naming its tap by +/// the raw key bytes (#1677). The bytes are written as given, so a test can stage an ID the plan +/// does not have, or one that does not fit the field. #[allow(clippy::too_many_arguments)] -fn stage_command( +fn stage_record( host: &mut AudioWorkletEngineHost, index: usize, kind: u32, rack: u8, - channel: u8, - track_index: u32, - effect_index: u32, - parameter_id: u32, - smoothing_samples: u32, - values: [f32; 4], + strip_id: &[u8], + effect_id: &[u8], + tap_id: u32, + window_blocks: u32, ) { let record_bytes = COMMAND_RECORD_BYTES as usize; + let field = RECORD_ID_FIELD_BYTES as usize; let staging = host .command_staging_mut() .expect("prepared command staging"); @@ -1959,14 +1962,63 @@ fn stage_command( record.fill(0); record[0] = u8::try_from(kind).expect("frozen kind"); record[1] = rack; - record[2] = channel; - record[4..8].copy_from_slice(&track_index.to_le_bytes()); - record[8..12].copy_from_slice(&effect_index.to_le_bytes()); - record[12..16].copy_from_slice(¶meter_id.to_le_bytes()); - record[16..20].copy_from_slice(&smoothing_samples.to_le_bytes()); - for (slot, value) in values.iter().enumerate() { - record[24 + slot * 4..28 + slot * 4].copy_from_slice(&value.to_le_bytes()); - } + record[2] = u8::try_from(strip_id.len()).expect("strip ID length"); + record[3] = u8::try_from(effect_id.len()).expect("effect ID length"); + record[4..8].copy_from_slice(&tap_id.to_le_bytes()); + record[8..12].copy_from_slice(&window_blocks.to_le_bytes()); + let strip_field = &mut record[16..16 + field]; + strip_field[..strip_id.len().min(field)] + .copy_from_slice(&strip_id[..strip_id.len().min(field)]); + let effect_field = &mut record[16 + field..16 + 2 * field]; + effect_field[..effect_id.len().min(field)] + .copy_from_slice(&effect_id[..effect_id.len().min(field)]); +} + +/// The stable key of the strip at `track` (tracks, then submixes) and of the console slot or insert +/// at `effect` of `rack` on the host's rendering plan; an index the plan does not have names an ID +/// it does not have either. +fn key_at(host: &AudioWorkletEngineHost, track: u32, rack: u8, effect: u32) -> (Vec, Vec) { + let ready = host.ready.as_ref().expect("ready"); + let strips = ready.shape.tracks.iter().chain(ready.shape.submixes.iter()); + let strip = strips.clone().nth(track as usize).map_or_else( + || format!("no-strip-{track}").into_bytes(), + |id| id.as_bytes().to_vec(), + ); + let effect_id = live_effect_address(u32::from(rack), effect) + .and_then(|address| ready.effect_slot(track as usize, address)) + .and_then(|slot| ready.shape.effect_instances.get(slot)?.as_ref()) + .map_or_else( + || format!("no-effect-{effect}").into_bytes(), + |instance| instance.effect_id.as_bytes().to_vec(), + ); + (strip, effect_id) +} + +/// Stage one record that names its tap by the position the fixture's plan gives it, translated to +/// the stable key the record carries: the strip at `track_index` and the console slot or insert at +/// `effect_index`. +#[allow(clippy::too_many_arguments)] +fn stage_command( + host: &mut AudioWorkletEngineHost, + index: usize, + kind: u32, + rack: u8, + track_index: u32, + effect_index: u32, + tap_id: u32, + window_blocks: u32, +) { + let (strip, effect) = key_at(host, track_index, rack, effect_index); + stage_record( + host, + index, + kind, + rack, + &strip, + &effect, + tap_id, + window_blocks, + ); } /// A live-control host over the browser identity fixture: one track, unity everything, one-quantum @@ -2078,12 +2130,10 @@ fn stage_observe_subscribe_at( index, COMMAND_OBSERVE_SUBSCRIBE, RACK_INSERTS, - 255, track, effect, 1, 2, - [0.0; 4], ); } @@ -2119,8 +2169,7 @@ fn idle_render_skips_admission_counter_clear_without_losing_queue_credit() { // The observed compressor declares one tap, so its observation queue holds one subscription // between two renders (#1345 D2: one slot per declared tap). let ready = host.ready.as_ref().expect("ready"); - let queue_depth = ready - .effect_producer_position(0) + let queue_depth = ReadyOwnership::effect_producer_position(&ready.shape, 0) .and_then(|position| ready.control.effect_producers().get(position)) .map(|producer| producer.observe_capacity()) .expect("the compressor's producer"); @@ -2265,18 +2314,7 @@ fn idle_render_skips_admission_counter_clear_without_losing_queue_credit() { // names one observation tap twice included (a single submission cannot overfill the queue, so // that is the only way for one to be refused here). let mut refused = observed_effect_live_control_host(QUANTUM); - stage_command( - &mut refused, - 0, - 3, - 255, - 2, - 0, - 0, - 0, - 0, - [-6.0, 0.0, 0.0, 0.0], - ); + stage_command(&mut refused, 0, 3, 255, 0, 0, 0, 0); assert_eq!(refused.submit_commands(1), RESULT_INVALID_ARGUMENT); assert!( !refused @@ -2322,18 +2360,7 @@ fn idle_render_skips_admission_counter_clear_without_losing_queue_credit() { // A refusal after accepted records preserves those records and the dirty flag. stage_observe_subscribe(&mut refused, 0); assert_eq!(refused.submit_commands(1), RESULT_OK); - stage_command( - &mut refused, - 0, - 3, - 255, - 2, - 0, - 0, - 0, - 0, - [-6.0, 0.0, 0.0, 0.0], - ); + stage_command(&mut refused, 0, 3, 255, 0, 0, 0, 0); assert_eq!(refused.submit_commands(1), RESULT_INVALID_ARGUMENT); assert!( refused @@ -2435,6 +2462,42 @@ fn a_live_control_free_host_refuses_every_live_kind_as_unsupported() { ); } +/// A record whose strip ID or effect ID has length zero is refused `MALFORMED` at its index in pass +/// one, before anything is admitted: a stable ID is 1..=127 bytes, so an empty one names nothing. +/// +/// Test value: red if an empty ID decodes (the record then reaches key resolution and is refused +/// as an unknown strip or effect, a reason that says the record named something the plan lacks). +#[test] +fn a_record_with_an_empty_id_is_refused_malformed_at_its_index() { + const QUANTUM: u32 = 128; + let mut host = observed_effect_live_control_host(QUANTUM); + feed_and_render(&mut host, 1, 0, 0.75); + let (strip, effect) = key_at(&host, 0, RACK_INSERTS, 0); + for (strip_id, effect_id) in [(&[][..], &effect[..]), (&strip[..], &[][..])] { + stage_observe_subscribe(&mut host, 0); + stage_record( + &mut host, + 1, + COMMAND_OBSERVE_SUBSCRIBE, + RACK_INSERTS, + strip_id, + effect_id, + 1, + QUANTUM, + ); + assert_eq!(host.submit_commands(2), RESULT_INVALID_ARGUMENT); + let report = *host.command_report(); + assert_eq!(report.reason, COMMAND_REASON_MALFORMED); + assert_eq!(report.rejected_index, 1); + assert_eq!(report.admitted, 0); + assert_eq!(host.observation_armed_taps(), 0); + assert!( + !host.ready.as_ref().expect("ready").has_in_flight_commands, + "nothing was queued" + ); + } +} + /// #1565A gate 1: a record of a deleted value kind (1-6 and 9-17) is refused `MALFORMED` at its /// index, before anything is admitted, and changes no cell. The browser keeps no value admission of /// its own: every value goes through `control_command`, so these numbers are only retired. @@ -2459,18 +2522,7 @@ fn every_deleted_kind_is_refused_malformed_at_its_index_and_changes_no_cell() { for kind in (1..=6_u32).chain(9..=17) { // Alone: refused at index 0. - stage_command( - &mut host, - 0, - kind, - 255, - 2, - 0, - 0, - 0, - QUANTUM, - [-6.0, 0.0, 0.0, 0.0], - ); + stage_command(&mut host, 0, kind, 255, 0, 0, 0, QUANTUM); assert_eq!( host.submit_commands(1), RESULT_INVALID_ARGUMENT, @@ -2482,18 +2534,7 @@ fn every_deleted_kind_is_refused_malformed_at_its_index_and_changes_no_cell() { assert_eq!(report.admitted, 0, "kind {kind}"); // Behind a valid subscription: refused at index 1, and the subscription is not armed. stage_observe_subscribe(&mut host, 0); - stage_command( - &mut host, - 1, - kind, - 255, - 2, - 0, - 0, - 0, - QUANTUM, - [-6.0, 0.0, 0.0, 0.0], - ); + stage_command(&mut host, 1, kind, 255, 0, 0, 0, QUANTUM); assert_eq!( host.submit_commands(2), RESULT_INVALID_ARGUMENT, @@ -2510,7 +2551,7 @@ fn every_deleted_kind_is_refused_malformed_at_its_index_and_changes_no_cell() { ); } // An unknown kind byte is malformed, not an unknown target. - stage_command(&mut host, 0, 0, 255, 255, 0, 0, 0, 0, [0.0; 4]); + stage_command(&mut host, 0, 0, 255, 0, 0, 0, 0); assert_eq!(host.submit_commands(1), RESULT_INVALID_ARGUMENT); assert_eq!(host.command_report().reason, COMMAND_REASON_MALFORMED); @@ -3838,21 +3879,52 @@ fn observe( } else { COMMAND_OBSERVE_UNSUBSCRIBE }; - stage_command( + stage_command(host, 0, kind, rack, track, effect, tap_id, window_blocks); + host.submit_commands(1) +} + +/// [`observe`] naming the tap by its stable key: the strip's ID, the console slot's or insert's ID +/// and the tap's ID (#1677), whatever the plan that renders calls its position. +fn observe_key( + host: &mut AudioWorkletEngineHost, + strip_id: &str, + rack: u8, + effect_id: &str, + tap_id: u32, + window_blocks: u32, + armed: bool, +) -> u32 { + let kind = if armed { + COMMAND_OBSERVE_SUBSCRIBE + } else { + COMMAND_OBSERVE_UNSUBSCRIBE + }; + stage_record( host, 0, kind, rack, - 255, - track, - effect, + strip_id.as_bytes(), + effect_id.as_bytes(), tap_id, window_blocks, - [0.0; 4], ); host.submit_commands(1) } +/// The armed-tap mask of the insert `insert_id` of strip `strip_id` on the plan that renders, or +/// `None` when the plan has no such insert. +fn armed_mask(host: &AudioWorkletEngineHost, strip_id: &str, insert_id: &str) -> Option { + let ready = host.ready.as_ref().expect("ready"); + let strip = strip_index( + &ready.shape.tracks, + &ready.shape.submixes, + strip_id.as_bytes(), + )?; + let effect = ready.effect_by_id(strip, LiveEffectRack::Inserts, insert_id.as_bytes())?; + ready.shape.observation_armed.get(effect).copied() +} + /// The frame's gain-reduction section: one non-negative magnitude per track, then the master's. /// The submix words between them (issue #1209 D1) are skipped. fn gain_reduction(host: &AudioWorkletEngineHost) -> (Vec, Option) { @@ -4097,21 +4169,34 @@ fn observation_misuse_is_typed_and_all_or_nothing() { ); assert_eq!(host.command_report().reason, COMMAND_REASON_UNKNOWN_TAP); - // A nonzero value word on a subscription is a caller mistake, not a meaningful field. - stage_command( - &mut host, - 0, - COMMAND_OBSERVE_SUBSCRIBE, - 1, - 255, - 0, - 0, - 1, - 2, - [1.0, 0.0, 0.0, 0.0], - ); - assert_eq!(host.submit_commands(1), RESULT_INVALID_ARGUMENT); - assert_eq!(host.command_report().reason, COMMAND_REASON_MALFORMED); + // A record that does not say what it names is malformed, not an unknown target: a nonzero + // reserved word, a byte after the end of an ID, a length that fills the whole field. + let (strip, effect) = key_at(&host, 0, RACK_INSERTS, 0); + let field = RECORD_ID_FIELD_BYTES as usize; + for (name, offset, value) in [ + ("reserved word", 12, 1_u8), + ("byte after the strip ID", 16 + strip.len(), 1), + ("byte after the effect ID", 16 + field + effect.len(), 1), + ( + "strip ID length fills the field", + 2, + RECORD_ID_FIELD_BYTES as u8, + ), + ( + "effect ID length fills the field", + 3, + RECORD_ID_FIELD_BYTES as u8, + ), + ] { + stage_command(&mut host, 0, COMMAND_OBSERVE_SUBSCRIBE, 1, 0, 0, 1, 2); + host.command_staging_mut().expect("staging")[offset] = value; + assert_eq!(host.submit_commands(1), RESULT_INVALID_ARGUMENT, "{name}"); + assert_eq!( + host.command_report().reason, + COMMAND_REASON_MALFORMED, + "{name}" + ); + } // Unknown rack and unknown effect keep their own reasons on the observe kinds. The retired // `simd1`/`simd2` codes are unknown racks (issue #1096); this session has no console slot, so @@ -4184,18 +4269,7 @@ fn observation_misuse_is_typed_and_all_or_nothing() { let before = host.meter_frame().to_vec(); assert_eq!(observe(&mut host, 0, 1, 0, 1, 2, true), RESULT_OK); for index in 0..2 { - stage_command( - &mut host, - index, - COMMAND_OBSERVE_SUBSCRIBE, - 1, - 255, - 0, - 0, - 1, - 4, - [0.0; 4], - ); + stage_command(&mut host, index, COMMAND_OBSERVE_SUBSCRIBE, 1, 0, 0, 1, 4); } assert_eq!( host.submit_commands(2), @@ -4267,12 +4341,10 @@ fn a_submission_names_each_observation_tap_once() { 1, COMMAND_OBSERVE_UNSUBSCRIBE, RACK_INSERTS, - 255, 0, 0, 1, 2, - [0.0; 4], ); assert_eq!(host.submit_commands(2), RESULT_INVALID_ARGUMENT); let report = *host.command_report(); @@ -4399,7 +4471,7 @@ fn native_observation_timeline_digest_pins_the_wasm_parity() { let subscribe = |host: &mut AudioWorkletEngineHost, kind: u32, tap: u32, window: u32| { // A boot with no taps has no command staging; its submission is refused as such. if host.command_staging_mut().is_some() { - stage_command(host, 0, kind, 1, 255, 0, 0, tap, window, [0.0; 4]); + stage_command(host, 0, kind, 1, 0, 0, tap, window); } host.submit_commands(1); *host.command_report() @@ -4560,40 +4632,19 @@ fn a_computed_tap_is_refused_with_unsupported_kind() { observations: &MENU, }; - let record = |tap_id: u32| CommandRecord { - kind: COMMAND_OBSERVE_SUBSCRIBE, - rack: 1, - channel: 255, - track_index: 0, - effect_index: 0, - parameter_id: tap_id, - smoothing_samples: 2, - values: [0.0; 4], + // The control plane resolves the tap (#1677); the host maps its refusal to the wire's reason. + let reason = |tap_id: u32| { + control_plane::resolve_observation_tap(&DESCRIPTOR, tap_id).map_err(crate::key_refusal) }; - // The resident tap resolves and binds. - assert!(record(1).into_observe_record(&DESCRIPTOR, true).is_ok()); + assert_eq!(reason(1), Ok(0)); // The computed tap resolves and is refused for what it is -- never `UnknownTap`, which would // say the address was wrong, and never silently bound. - assert_eq!( - record(2).into_observe_record(&DESCRIPTOR, true).err(), - Some(COMMAND_REASON_UNSUPPORTED_KIND) - ); - // A tap id the menu does not declare stays `UnknownTap` on the same descriptor. - assert_eq!( - record(3).into_observe_record(&DESCRIPTOR, true).err(), - Some(COMMAND_REASON_UNKNOWN_TAP) - ); - // And with no bound lane, the resident tap is `ObservationUnbound` while the computed one is - // still `UnsupportedKind`: the cost class is a property of the *effect*, checked first. - assert_eq!( - record(1).into_observe_record(&DESCRIPTOR, false).err(), - Some(COMMAND_REASON_OBSERVATION_UNBOUND) - ); - assert_eq!( - record(2).into_observe_record(&DESCRIPTOR, false).err(), - Some(COMMAND_REASON_UNSUPPORTED_KIND) - ); + assert_eq!(reason(2), Err(COMMAND_REASON_UNSUPPORTED_KIND)); + // A tap id the menu does not declare stays `UnknownTap` on the same descriptor, and so does + // the reserved zero. + assert_eq!(reason(3), Err(COMMAND_REASON_UNKNOWN_TAP)); + assert_eq!(reason(0), Err(COMMAND_REASON_UNKNOWN_TAP)); } /// Issue #143 R4: the one unit conversion the design permits, and where it happens. @@ -5565,12 +5616,10 @@ fn a_bus_observation_admits_and_renders_without_allocating() { 0, COMMAND_OBSERVE_SUBSCRIBE, RACK_INSERTS, - 255, STRIP_BUS, 0, 1, 2, - [0.0; 4], ); let planes: Vec<[Vec; 2]> = (0..3).map(|feeder| strip_planes(feeder, 2)).collect(); for (feeder, planes) in planes.iter().enumerate() { @@ -6624,6 +6673,7 @@ mod structural_apply { /// A live fader edit of strip `id` through the production control entry, as the SDK sends it. fn live_fader(rig: &mut Rig, id: &str, db: f32, request: u64) { let edits = [SessionEdit::SetControl { + expected_effect: None, address: controls::ControlAddress { owner: controls::Owner::Strip(StableId::parse(id).expect("strip ID")), section: controls::Section::Builtins, @@ -6928,11 +6978,43 @@ mod structural_apply { assert_eq!(rig.host.session_source_id(1), Some("fixture-source")); assert_eq!(ids(&rig.host), ["track"]); assert_eq!(binding_tracks(&rig.host), [0]); - // An observation record is admitted against the successor, as a live value is: index 1 - // exists only in the successor's tables (the old track's compressor, shifted by the added - // track), and nothing is armed on the plan that renders. + // An observation record is admitted against the successor, as a live value is, and names + // its tap by key (#1677): "track"'s compressor, which sits at index 1 in the successor + // (the added "a-track", with a compressor of the same ID, took index 0), is the one that + // arms. Nothing is armed on the plan that renders. + let key = |host: &mut AudioWorkletEngineHost, strip: &str| { + observe_key(host, strip, RACK_INSERTS, "comp", 1, 2, true) + }; + // A key the successor does not have is refused with its typed code and queues nothing, + // whichever plan has it: "gone" has no strip anywhere, "a-track" is the successor's. + assert_eq!(key(&mut rig.host, "gone"), RESULT_INVALID_ARGUMENT); assert_eq!( - observe(&mut rig.host, 1, RACK_INSERTS, 0, 1, 2, true), + rig.host.command_report().reason, + COMMAND_REASON_UNKNOWN_TRACK + ); + assert_eq!( + observe_key(&mut rig.host, "track", RACK_INSERTS, "gone", 1, 2, true), + RESULT_INVALID_ARGUMENT + ); + assert_eq!( + rig.host.command_report().reason, + COMMAND_REASON_UNKNOWN_EFFECT + ); + assert_eq!( + observe_key(&mut rig.host, "track", RACK_INSERTS, "comp", 9, 2, true), + RESULT_INVALID_ARGUMENT + ); + assert_eq!(rig.host.command_report().reason, COMMAND_REASON_UNKNOWN_TAP); + assert!( + !rig.host + .ready + .as_ref() + .expect("ready") + .has_in_flight_commands, + "a refused key queued nothing" + ); + assert_eq!( + key(&mut rig.host, "track"), RESULT_OK, "an observation record between the commit and the adoption block is admitted" ); @@ -6943,7 +7025,7 @@ mod structural_apply { "the plan that renders is not armed by a record addressed to its successor" ); assert_eq!( - observe(&mut rig.host, 1, RACK_INSERTS, 0, 1, 2, true), + key(&mut rig.host, "track"), RESULT_BACKPRESSURE, "the record is queued on the successor: its one-slot queue is full until its block" ); @@ -6961,6 +7043,14 @@ mod structural_apply { 1, "the record took effect on the successor: the old track's compressor is armed" ); + assert_eq!( + ( + armed_mask(&rig.host, "track", "comp"), + armed_mask(&rig.host, "a-track", "comp") + ), + (Some(1), Some(0)), + "the record armed the compressor it named, not the one that took its old index" + ); assert_eq!(rig.host.service(None), RESULT_OK); for _ in 0..4 { rig.step(); @@ -7081,6 +7171,7 @@ mod structural_apply { let boot = rig.host.meter_header().structure_epoch; assert_eq!(boot, 1, "the boot list is the first structure"); let delay = SessionEdit::SetControl { + expected_effect: None, address: controls::ControlAddress { owner: controls::Owner::Strip(model.tracks[0].id.clone()), section: controls::Section::Builtins, @@ -7533,7 +7624,7 @@ mod structural_apply { } )); assert_eq!( - observe(&mut rig.host, 1, RACK_INSERTS, 0, 1, 2, false), + observe_key(&mut rig.host, "track", RACK_INSERTS, "comp", 1, 2, false), RESULT_OK, "the disarm is admitted against the successor, still at sample zero" ); @@ -7945,3 +8036,155 @@ mod structural_apply { ); } } + +/// A subscription past the engine's cap on armed taps (#1676) is refused as a whole before any +/// record is queued, so the acknowledgement never promises a tap the engine could not end honestly; +/// unsubscribing one tap makes room for the next. The cap is the control plane's, shared with the C +/// ABI: the browser only reports the refusal, as its own non-retryable reason `observationLimit` +/// with `RESULT_UNSUPPORTED`, never as backpressure, at the record that crossed it. +/// +/// Red if the browser admits a subscription past the cap (the later removal of every subscribed tap +/// could not be told to its subscribers), if it reports the cap as backpressure (the SDK would +/// resend it after every render), if the refusal still queues or arms the tap, if a refused batch +/// moves the armed count or keeps part of itself, if the refusal names a record other than the arm +/// that crossed (index 2 of a batch that first unsubscribes), or if an unsubscribe does not give its +/// place back. +#[test] +fn a_subscription_past_the_cap_is_refused_and_an_unsubscribe_makes_room() { + const QUANTUM: u32 = 128; + let document: serde_json::Value = + serde_json::from_str(include_str!("../tests/browser-v1/observation-session.json")) + .expect("fixture"); + let probe_cap = { + let options = WebBootOptions { + live_control_observation_taps: 1, + live_control_master_track_plus_one: 1, + ..boot_options(QUANTUM) + }; + let host = AudioWorkletEngineHost::boot( + include_bytes!("../tests/browser-v1/observation-session.json"), + options, + ) + .expect("boot"); + host.ready + .as_ref() + .expect("ready") + .control + .observation_cap() + }; + let inserts = probe_cap + 2; + let mut document = document; + let template = document["tracks"][0]["inserts"]["effects"][0].clone(); + document["tracks"][0]["inserts"]["effects"] = serde_json::Value::Array( + (0..inserts) + .map(|index| { + let mut effect = template.clone(); + effect["id"] = serde_json::Value::String(format!("ins{index:03}")); + effect + }) + .collect(), + ); + let document = serde_json::to_string_pretty(&document).expect("document"); + let options = WebBootOptions { + live_control_observation_taps: 1, + live_control_master_track_plus_one: 1, + ..boot_options(QUANTUM) + }; + let mut host = AudioWorkletEngineHost::boot(document.as_bytes(), options).expect("boot"); + let submit = |host: &mut AudioWorkletEngineHost, kind: u32, effect: u32| { + stage_command(host, 0, kind, 1, 0, effect, 1, 2); + host.submit_commands(1); + *host.command_report() + }; + // A render drains the effects' observation queues, one slot per tap. + let mut block = 0_u64; + let mut render = |host: &mut AudioWorkletEngineHost| { + let plane = vec![0.5_f32; QUANTUM as usize]; + let planes: [&[f32]; 2] = [&plane, &plane]; + assert_eq!( + host.submit_source( + b"fixture-source", + 1, + block * u64::from(QUANTUM), + 48_000, + &planes, + QUANTUM, + false, + ), + RESULT_OK, + ); + assert_eq!(host.render_next(), RESULT_OK); + block += 1; + }; + let armed = |host: &AudioWorkletEngineHost| { + host.ready + .as_ref() + .expect("ready") + .control + .observation_armed() + }; + for effect in 0..probe_cap { + let report = submit(&mut host, COMMAND_OBSERVE_SUBSCRIBE, effect as u32); + assert_eq!(report.result, RESULT_OK, "{effect}: {report:?}"); + } + assert_eq!(armed(&host), probe_cap); + let refused = submit(&mut host, COMMAND_OBSERVE_SUBSCRIBE, probe_cap as u32); + assert_eq!( + ( + refused.result, + refused.reason, + refused.rejected_index, + refused.admitted + ), + (RESULT_UNSUPPORTED, COMMAND_REASON_OBSERVATION_LIMIT, 0, 0), + "{refused:?}" + ); + assert_eq!( + armed(&host), + probe_cap, + "a refused subscription arms nothing" + ); + render(&mut host); + // A batch that unsubscribes one tap, takes its place and then crosses the cap is refused whole, + // at the record that crossed: wire index 2, not the first record or the first arm. + let cap = probe_cap as u32; + stage_command(&mut host, 0, COMMAND_OBSERVE_UNSUBSCRIBE, 1, 0, 0, 1, 2); + stage_command(&mut host, 1, COMMAND_OBSERVE_SUBSCRIBE, 1, 0, cap, 1, 2); + stage_command(&mut host, 2, COMMAND_OBSERVE_SUBSCRIBE, 1, 0, cap + 1, 1, 2); + host.submit_commands(3); + let refused = *host.command_report(); + assert_eq!( + ( + refused.result, + refused.reason, + refused.rejected_index, + refused.admitted + ), + (RESULT_UNSUPPORTED, COMMAND_REASON_OBSERVATION_LIMIT, 2, 0), + "{refused:?}" + ); + assert_eq!( + armed(&host), + probe_cap, + "the refused batch's unsubscribe and first arm are not kept" + ); + render(&mut host); + // A window change on an armed tap adds no tap. + assert_eq!( + submit(&mut host, COMMAND_OBSERVE_SUBSCRIBE, 0).result, + RESULT_OK + ); + render(&mut host); + // An unsubscribe gives its place back. + assert_eq!( + submit(&mut host, COMMAND_OBSERVE_UNSUBSCRIBE, 0).result, + RESULT_OK + ); + assert_eq!(armed(&host), probe_cap - 1); + render(&mut host); + assert_eq!( + submit(&mut host, COMMAND_OBSERVE_SUBSCRIBE, probe_cap as u32).result, + RESULT_OK + ); + assert_eq!(armed(&host), probe_cap); +} diff --git a/hosts/host-web/tests/browser-v1/direct-oracle.mjs b/hosts/host-web/tests/browser-v1/direct-oracle.mjs index fc342299b..05ab941f4 100644 --- a/hosts/host-web/tests/browser-v1/direct-oracle.mjs +++ b/hosts/host-web/tests/browser-v1/direct-oracle.mjs @@ -110,22 +110,24 @@ const commandField = (name) => ABI_LAYOUT.commandRecord.fields .find((field) => field.name === name).offset; const structureField = (structure, name) => ABI_LAYOUT.structures[structure].fields .find((field) => field.name === name).offset; -/// Encode only the generated wire record. Preparation and admission stay in the shared helper. +/// Encode only the generated wire record: a record names its tap by stable key (#1677). +/// Preparation and admission stay in the shared helper. function encodeCommands(records) { const bytes = new Uint8Array(records.length * COMMAND_RECORD_BYTES); const view = new DataView(bytes.buffer); + const encoder = new TextEncoder(); for (const [index, record] of records.entries()) { const offset = index * COMMAND_RECORD_BYTES; + const stripId = encoder.encode(record.stripId); + const effectId = encoder.encode(record.effectId); view.setUint8(offset + commandField("kind"), record.kind); - view.setUint8(offset + commandField("rack"), record.rack ?? 255); - view.setUint8(offset + commandField("channel"), record.channel ?? 255); - view.setUint32(offset + commandField("trackIndex"), record.trackIndex, true); - view.setUint32(offset + commandField("effectIndex"), record.effectIndex ?? 0, true); - view.setUint32(offset + commandField("parameterId"), record.parameterId ?? 0, true); - view.setUint32(offset + commandField("smoothingSamples"), record.smoothingSamples ?? 0, true); - for (let slot = 0; slot < 4; slot += 1) { - view.setFloat32(offset + commandField("values") + slot * 4, record.values[slot], true); - } + view.setUint8(offset + commandField("rack"), record.rack); + view.setUint8(offset + commandField("stripIdBytes"), stripId.length); + view.setUint8(offset + commandField("effectIdBytes"), effectId.length); + view.setUint32(offset + commandField("tapId"), record.tapId, true); + view.setUint32(offset + commandField("windowBlocks"), record.windowBlocks, true); + bytes.set(stripId, offset + commandField("stripId")); + bytes.set(effectId, offset + commandField("effectId")); } return bytes; } @@ -421,11 +423,10 @@ async function runObservationTimeline(modulePath, sessionDocument, sourceId, tap ); }; const subscribe = { - kind: COMMAND_OBSERVE_SUBSCRIBE, rack: 1, channel: 255, trackIndex: 0, - effectIndex: 0, parameterId: 1, smoothingSamples: OBSERVATION_WINDOW_BLOCKS, - values: [0, 0, 0, 0], + kind: COMMAND_OBSERVE_SUBSCRIBE, rack: 1, stripId: "track", effectId: "comp", tapId: 1, + windowBlocks: OBSERVATION_WINDOW_BLOCKS, }; - const unsubscribe = { ...subscribe, kind: COMMAND_OBSERVE_UNSUBSCRIBE, smoothingSamples: 0 }; + const unsubscribe = { ...subscribe, kind: COMMAND_OBSERVE_UNSUBSCRIBE, windowBlocks: 0 }; const blocks = []; const step = (block) => { assert.equal(feed(block), 0); @@ -434,7 +435,7 @@ async function runObservationTimeline(modulePath, sessionDocument, sourceId, tap }; const reports = {}; - reports.unknownTap = submitCommands(exports, handle, [{ ...subscribe, parameterId: 9 }]); + reports.unknownTap = submitCommands(exports, handle, [{ ...subscribe, tapId: 9 }]); reports.subscribe = submitCommands(exports, handle, [subscribe]); for (let block = 0; block < 8; block += 1) step(block); const armed = { diff --git a/hosts/host-web/tests/control_admission.rs b/hosts/host-web/tests/control_admission.rs index aa07ed9f9..235962564 100644 --- a/hosts/host-web/tests/control_admission.rs +++ b/hosts/host-web/tests/control_admission.rs @@ -92,6 +92,7 @@ fn vca_row(control: u32, lane: Lane) -> ControlAddress { fn set(address: ControlAddress, value: f32, ramp: Option) -> SessionEdit { SessionEdit::SetControl { + expected_effect: None, address, value, ramp_samples: ramp, diff --git a/hosts/host-web/tests/control_command_timing.rs b/hosts/host-web/tests/control_command_timing.rs index 906ec042c..c112e5ff7 100644 --- a/hosts/host-web/tests/control_command_timing.rs +++ b/hosts/host-web/tests/control_command_timing.rs @@ -29,6 +29,7 @@ const CALLS: u64 = 200; fn frame(request: u64, revision: u64, strip: &str, db: f32) -> Vec { let edits = [SessionEdit::SetControl { + expected_effect: None, address: ControlAddress { owner: Owner::Strip(StableId::parse(strip).expect("strip ID")), section: Section::Builtins, @@ -191,6 +192,7 @@ fn a_vca_move_at_the_bound_fits_a_quantum_in_release() { let mut times = Vec::new(); for (revision, call) in (model.revision..).zip(0..CALLS) { let edits = [SessionEdit::SetControl { + expected_effect: None, address: ControlAddress { owner: Owner::Vca(StableId::parse("v0000").expect("VCA ID")), section: Section::Own, diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 11ebb200d..9229f0033 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -980,6 +980,7 @@ impl CAbi { /// The `SET_CONTROL` edits of one scripted edit. fn edits(edit: Edit, ramp: Option) -> Vec { let set = |address: ControlAddress, value: f32| SessionEdit::SetControl { + expected_effect: None, address, value, ramp_samples: ramp, @@ -1168,6 +1169,7 @@ impl CAbi { Edit::FaderLaneRamps { strip } => [(Lanes::Left, 64), (Lanes::Right, 480)] .into_iter() .map(|(lanes, length)| SessionEdit::SetControl { + expected_effect: None, address: Self::strip_address(strip, FADER_DB, lanes), value: -5.0, ramp_samples: Some(length), @@ -1303,9 +1305,13 @@ impl CAbi { /// One scripted edit as one transaction at the committed revision, and the reply frame's bytes. fn edit(&mut self, edit: Edit, ramp: Option) -> (Verdict, Vec) { - let edits = Self::edits(edit, ramp); + self.transaction(&Self::edits(edit, ramp)) + } + + /// `edits` as one transaction at the committed revision, and the reply frame's bytes. + fn transaction(&mut self, edits: &[SessionEdit]) -> (Verdict, Vec) { let response = self.send( - CommandPayload::SessionTransactionApply(&edits), + CommandPayload::SessionTransactionApply(edits), ExpectedRevision::Exact(SessionRevision(self.revision)), ); let verdict = decode_verdict(&response); @@ -1844,6 +1850,134 @@ fn the_status_words_carry_a_rebuilds_watermark_the_c_abi_reports() { } } +/// A reply frame with its request ID (header bytes 24 to 32) zeroed: each host numbers its own +/// requests, and nothing else of a reply depends on the count. +fn without_request_id(mut reply: Vec) -> Vec { + reply[24..32].fill(0); + reply +} + +/// Blocks `range` on both hosts, equal bit for bit. +fn same_blocks(browser: &mut Rig, c_abi: &mut CAbi, range: std::ops::Range, label: &str) { + for block in range { + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "block {block} {label}"); + } +} + +/// #1678: an effect-row edit that names the native effect it was written for is refused by the +/// core, with `control.effect.changed`, once a rebuild has replaced that effect, and nothing +/// changes; the same edit without the expected effect, and one that names the effect that runs now, +/// commit live as before. Both hosts answer alike: one verdict, the same reply bytes, the same +/// control rows and document bytes before and after the refusal, the same bits of every block. +/// +/// Test value: red if either host skips the check (the bypass row 0 exists on every effect, so +/// the stale edit would land on the new effect's bypass and commit), if a host reports a different +/// code, or if the refused edit leaves the document or the rendered blocks different from the C ABI. +#[test] +fn a_stale_effect_row_edit_is_refused_alike_on_both_hosts() { + let replace = SessionEdit::SetEffectIdentity { + track_id: id(SHAPER.track.id), + rack_name: session::RackName::Inserts, + effect_id: id(SHAPER.id), + identity: session::EffectIdentity::Native { + effect_id: id(CLIP.native), + }, + }; + let bypass = |expected: Option<&str>, bypassed: bool| { + let edit = SessionEdit::set_control( + address( + Owner::Strip(id(SHAPER.track.id)), + Section::Inserts(id(SHAPER.id)), + controls::BYPASS_CONTROL, + ParameterChannel::Both, + ), + f32::from(u8::from(bypassed)), + None, + ); + match expected { + Some(effect) => edit.with_expected_effect(id(effect)), + None => edit, + } + }; + for rate in RATES { + let model = model_at(rate); + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + same_blocks(&mut browser, &mut c_abi, 0..3, "before the rebuild"); + // The rebuild replaces the transient shaper with the soft clip; the swap is block 3. + let (b, b_reply) = browser.transaction_reply(std::slice::from_ref(&replace)); + let (c, c_reply) = c_abi.transaction(std::slice::from_ref(&replace)); + assert_eq!(b, c, "{rate} Hz: one verdict for the rebuild"); + assert_eq!(b_reply, c_reply); + assert!(matches!( + c, + Verdict::Applied { + path: EditPath::Rebuild, + .. + } + )); + same_blocks(&mut browser, &mut c_abi, 3..6, "after the swap"); + let before_b = dump(|payload, expected| browser.command(payload, expected)); + let before_c = dump(|payload, expected| c_abi.send(payload, expected)); + assert_eq!(before_b, before_c); + let revision = browser.revision; + // Written for the shaper, the insert runs the soft clip now. + let stale = [bypass(Some(SHAPER.native), true)]; + let (b, b_reply) = browser.transaction_reply(&stale); + let (c, c_reply) = c_abi.transaction(&stale); + assert_eq!(b, c, "{rate} Hz: one verdict for the stale edit"); + assert_eq!( + without_request_id(b_reply), + without_request_id(c_reply), + "{rate} Hz: one reply for the stale edit" + ); + assert!( + matches!(&c, Verdict::Refused { codes, revision: got } + if codes.iter().any(|code| code == "control.effect.changed") && *got == revision), + "{rate} Hz: refused as an effect change at the committed revision, got {c:?}" + ); + // Nothing changed: the control rows and the document are the bytes they were. + assert_eq!( + dump(|payload, expected| browser.command(payload, expected)), + before_b + ); + assert_eq!( + dump(|payload, expected| c_abi.send(payload, expected)), + before_c + ); + same_blocks(&mut browser, &mut c_abi, 6..8, "after the refusal"); + // Without an expected effect the same edit lands on whatever runs there: as before. + for (round, (expected, bypassed)) in [(None, true), (Some(CLIP.native), false)] + .into_iter() + .enumerate() + { + let edits = [bypass(expected, bypassed)]; + let (b, b_reply) = browser.transaction_reply(&edits); + let (c, c_reply) = c_abi.transaction(&edits); + assert_eq!(b, c, "{rate} Hz: one verdict for {expected:?}"); + assert_eq!(without_request_id(b_reply), without_request_id(c_reply)); + assert!( + matches!( + &c, + Verdict::Applied { + path: EditPath::Live, + .. + } + ), + "{rate} Hz: {expected:?} commits live, got {c:?}" + ); + let first = 8 + 2 * round as u64; + same_blocks( + &mut browser, + &mut c_abi, + first..first + 2, + "after a live edit", + ); + } + } +} + /// Block the bypass of `a_live_bypass_crossfades_alike_on_both_hosts` is submitted before. const BYPASS_BLOCK: u64 = 6; diff --git a/hosts/host-web/tests/protocol_cross_host.rs b/hosts/host-web/tests/protocol_cross_host.rs index 64b9d460e..c9fbe693e 100644 --- a/hosts/host-web/tests/protocol_cross_host.rs +++ b/hosts/host-web/tests/protocol_cross_host.rs @@ -29,9 +29,9 @@ use host_web::{ }; use protocol::{ CommandPayload, ControlListRequest, CounterId, CountersRequest, DiagnosticSeverity, - DiagnosticsRequest, ExpectedRevision, ProtocolCodec, RequestId, SampleTime, SessionEdit, - SessionRevision, SessionSnapshotRequest, TelemetryConfiguration, TransportSetRequest, - TransportState, TypedCommandFrame, + DiagnosticsRequest, ExpectedRevision, ProtocolCodec, RequestId, SessionEdit, SessionRevision, + SessionSnapshotRequest, TelemetryConfiguration, TransportSetRequest, TransportState, + TypedCommandFrame, }; use session::{ParameterChannel, StableId, canonical_session_json, parse_session_json}; @@ -92,6 +92,7 @@ fn frame(request: u64, expected: ExpectedRevision, payload: CommandPayload<'_>) fn fader_edit(db: f32) -> [SessionEdit; 1] { [SessionEdit::SetControl { + expected_effect: None, address: ControlAddress { owner: Owner::Strip(StableId::parse(TRACK_ID).expect("track ID")), section: Section::Builtins, @@ -621,12 +622,11 @@ fn the_browser_and_the_c_abi_give_the_same_bytes_for_one_script() { ); legs.both("3 transport get", &transport_get); - // 4. TRANSPORT_SET to playing at position 0. + // 4. TRANSPORT_SET to playing. legs.command( "4 transport set", CommandPayload::TransportSet(TransportSetRequest { state: TransportState::Playing, - position: Some(SampleTime(0)), }), ); legs.drain("after 4"); diff --git a/hosts/host-web/tests/render_locked_source.rs b/hosts/host-web/tests/render_locked_source.rs index 7fef3e527..db2e5b46b 100644 --- a/hosts/host-web/tests/render_locked_source.rs +++ b/hosts/host-web/tests/render_locked_source.rs @@ -135,7 +135,6 @@ fn command_exports(handle: u32, before: u32) { let mut record = [0_u8; COMMAND_RECORD_BYTES as usize]; record[0] = 3; record[1] = RACK_NOT_APPLICABLE; - record[2] = 0; assert!(native_staging::command_records(handle, &record)); unchanged( before, @@ -143,16 +142,19 @@ fn command_exports(handle: u32, before: u32) { submit(handle, 1), RESULT_INVALID_ARGUMENT, ); - // An observe record for a tap the console EQ does not declare is refused at the tap. + // An observe record naming a key the plan does not have is refused at the key. let mut record = [0_u8; COMMAND_RECORD_BYTES as usize]; record[0] = COMMAND_OBSERVE_SUBSCRIBE as u8; record[1] = RACK_CONSOLE; - record[2] = 255; - record[12..16].copy_from_slice(&1_u32.to_le_bytes()); + record[2] = 5; + record[3] = 4; + record[4..8].copy_from_slice(&1_u32.to_le_bytes()); + record[16..21].copy_from_slice(b"track"); + record[144..148].copy_from_slice(b"slot"); assert!(native_staging::command_records(handle, &record)); unchanged( before, - "command_submit (refused: unknown tap)", + "command_submit (refused: unknown key)", submit(handle, 1), RESULT_INVALID_ARGUMENT, ); diff --git a/hosts/host-web/tests/retained_ceilings.rs b/hosts/host-web/tests/retained_ceilings.rs index 8bb94522d..f077520af 100644 --- a/hosts/host-web/tests/retained_ceilings.rs +++ b/hosts/host-web/tests/retained_ceilings.rs @@ -263,7 +263,8 @@ fn browser_identity_fixture_retained_rows_stay_within_their_budgets() { /// The browser's graph row charges the allocation the plan makes for itself and not the session's /// (#1502 m1, #1312 m3, #1399). The browser copies host-core's graph row unchanged, and the graph /// compiler's row is its incremental row plus the session bytes the facade does not pass it, so the -/// row less the incremental row is the plan's own: its revision gate. The session's +/// row less the incremental row is the plan's own: its revision gate and its jump-length cell +/// (#1639 D1), whose reader holds the session's count and does not own it. The session's /// `live_values_superseded` count is one allocation every plan of the session shares, so the /// browser charges it once, in its bridge rows (the exact-decomposition tests in `src/tests.rs` /// add it there), and the plan's row must not charge it again. A row that leaves the gate out would @@ -277,7 +278,10 @@ fn the_graph_row_charges_the_plans_gate_and_not_the_sessions_superseded_count() let report = *host.resources(); assert_eq!( report.graph_session_plus_plan_bytes - report.graph_incremental_plan_bytes, - engine::realtime::plan_revision_gate_retained_bytes(), - "the browser's graph row: the graph's own bytes and the plan's revision gate" + // The jump-length cell, #1639 D1. + engine::realtime::plan_revision_gate_retained_bytes() + + host_core::jump_length_retained_bytes(), + "the browser's graph row: the graph's own bytes, the plan's revision gate and its \ + jump-length cell" ); } diff --git a/hosts/host-web/tests/structural_apply_realtime.rs b/hosts/host-web/tests/structural_apply_realtime.rs index 15151e706..5ef6f5fa8 100644 --- a/hosts/host-web/tests/structural_apply_realtime.rs +++ b/hosts/host-web/tests/structural_apply_realtime.rs @@ -241,6 +241,7 @@ fn run(script: Script) -> Run { { request += 1; let edit = [SessionEdit::SetControl { + expected_effect: None, address: ControlAddress { owner: Owner::Strip(StableId::parse(&edited).expect("strip ID")), section: Section::Builtins, diff --git a/hosts/host-web/tests/worker_admission_realtime.rs b/hosts/host-web/tests/worker_admission_realtime.rs index c142ae710..35a5e5252 100644 --- a/hosts/host-web/tests/worker_admission_realtime.rs +++ b/hosts/host-web/tests/worker_admission_realtime.rs @@ -42,6 +42,7 @@ const FADER_DB: u32 = 5; fn frame(request: u64, revision: u64, strip: &str, db: f32) -> Vec { let edits = [SessionEdit::SetControl { + expected_effect: None, address: ControlAddress { owner: Owner::Strip(StableId::parse(strip).expect("strip ID")), section: Section::Builtins, @@ -221,6 +222,7 @@ fn owner_edit(kind: u64, high: bool) -> SessionEdit { ), }; SessionEdit::SetControl { + expected_effect: None, address: ControlAddress { owner, section, 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 9002425ec..f2fe46311 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 @@ -30,13 +30,15 @@ // `liveControlObservationTaps === 0n` is that session: it has no command staging, so `command()` // acknowledges every batch `UnsupportedKind`. // -// ## Addressing is session-stable and string-free +// ## Addressing is by stable key (issue 1677) // -// A command names a strip by its strip index -- the compiled session's canonical normalized tracks -// first, then its submixes in canonical order (issue 1213), as the core's control list names them -// (the owner header rows, track rows first) -- plus a rack, an effect index and a numeric tap ID. -// No string crosses the command path. The identity mapping is the control list for the session -// and the build-time metadata JSON for the effect vocabulary. +// A command names its tap by the stable key the engine's `OBSERVATION_ENDED` event uses: the +// strip's ID (a track or a submix), the rack (`1` inserts, `3` console), the insert's or the +// console slot's ID, and the effect-local tap ID. It carries no position, so a rebuild cannot make +// a record arm another effect's tap. The engine resolves the key on the newest committed plan -- +// the successor between a rebuild's commit and the block that adopts it -- and refuses a key that +// plan does not have with a typed reason (`UnknownTrack`, `UnknownEffect`, `UnknownTap`) before it +// queues anything. The build-time metadata JSON is the vocabulary of the tap IDs. // // ## One batch is one transaction // @@ -189,10 +191,9 @@ export interface MisoUnsupportedBrowser { export const enum MisoCommandKind { /// Arm one declared observation tap of one effect instance. Applied (issue #143). /// - /// `parameterId` carries the effect-local **tap id** from the metadata JSON's per-effect - /// `observations` array, `smoothingSamples` carries the window length in render blocks (`0` for - /// the plan's default), and every `values` word must be `0`: a subscription changes what is read, - /// never what is rendered. + /// `tapId` carries the effect-local **tap id** from the metadata JSON's per-effect + /// `observations` array and `windowBlocks` the window length in render blocks (`0` for the + /// plan's default): a subscription changes what is read, never what is rendered. /// /// Applied on the `miso.observe.v1` plane: it binds an entry in the subscription map. The /// metadata JSON reports it as `"plane": "observation"`. @@ -208,18 +209,19 @@ export const enum MisoCommandKind { export const enum MisoCommandReason { /// The submission was admitted whole. None = 0, - /// Unknown kind, nonzero reserved word, non-finite value, a field set for the wrong kind, or a - /// submission that names one observation tap more than once. + /// Unknown kind, nonzero reserved word, an ID field that is not its length's bytes followed by + /// zeros, or a submission that names one observation tap more than once. Malformed = 1, - /// `trackIndex` is not a strip of the compiled session: it is at or past the control list's - /// track and submix owner rows together. The spelling is kept (issue 1213, DESIGN P17). + /// `stripId` is not a track or a submix of the newest committed plan. The spelling is kept (issue + /// 1213, DESIGN P17). UnknownTrack = 2, /// `rack` is not an effect rack (`1` inserts or `3` console) on an effect-addressed kind. The /// retired `simd1` (`0`) and `simd2` (`2`) codes are refused with it only on the raw export /// path (issue #1096); `MisoAudioWorkletHost.command()` refuses them locally, before the port, /// by rejecting with a `miso.error.v1` invalid argument, as it refuses any out-of-set code. UnknownRack = 3, - /// `effectIndex` is not an effect of the addressed rack: no such console slot or insert. + /// `effectId` is not a console slot or insert (per `rack`) of the addressed strip on the newest + /// committed plan. UnknownEffect = 4, /// `parameterId` is not a parameter of the addressed effect. UnknownParameter = 5, @@ -232,10 +234,12 @@ export const enum MisoCommandReason { /// drained; nothing was admitted, and a render clears it. The reason is also returned with the internal result /// code, not a backpressure one, when the plan's revision gate refuses the batch's stamp (a /// revision past the gate's ceiling); that batch wrote nothing. + /// + /// Never the answer to the cap on armed taps, which is `ObservationLimit`. Backpressure = 8, /// The engine is not `STATE_READY`. WrongState = 9, - /// `parameterId` is not a declared observation tap of the addressed effect (issue 143). + /// `tapId` is not a declared observation tap of the addressed effect (issue 143). /// /// Deliberately not `UnknownParameter`: a parameter and a tap are different namespaces on the /// same effect, and a caller that confuses them learns which one it got wrong. @@ -247,33 +251,36 @@ export const enum MisoCommandReason { ObservationUnbound = 11, // Reasons 12-14 (`NotSoloable`, `UnknownRoute`, `UnknownVca`) were deleted with the value kinds // (issue #1565); their numbers are never reused. + /// The subscription would leave more taps armed than the engine can end honestly in one + /// `OBSERVATION_ENDED` event in one control frame (issue 1676: 51 taps at the browser's 16 KiB + /// frame). `rejectedIndex` names the subscription that crossed the cap, after every unsubscribe + /// of the same submission is counted; nothing was admitted. The result is `RESULT_UNSUPPORTED`. + /// + /// A render does not make room, so retrying the same submission will not help, which is why this + /// is its own reason and not `Backpressure`: unsubscribe another tap first. + ObservationLimit = 15, } -/// One live-control command record. `255` means "not applicable to this kind". +/// One live-control command record: the subscription or withdrawal of one tap, named by its stable +/// key (issue 1677). /// /// Only the two observation kinds are carried (issue #1565). An effect is addressed in the -/// session's terms (decision 12, issue #1096): a console slot as `rack` `3` with `effectIndex` the -/// slot's index in the session's slot order (`pre_insert`, then `post_insert`, which is its index in -/// the track's `console` entries), and an insert as `rack` `1` with `effectIndex` its index in the -/// track's `inserts`. The retired `simd1` (`0`) and `simd2` (`2`) codes are refused and never -/// reallocated; the layout JSON's `racks` table is the vocabulary. +/// session's terms (decision 12, issue #1096): a console slot as `rack` `3` with `effectId` the +/// slot's ID, and an insert as `rack` `1` with `effectId` the insert's ID. The retired `simd1` (`0`) +/// and `simd2` (`2`) codes are refused and never reallocated; the layout JSON's `racks` table is the +/// vocabulary. An ID is at most 127 UTF-8 bytes; a longer one rejects locally. export interface MisoCommand { kind: MisoCommandKind; /// `1` inserts, `3` console. rack: number; - /// `0` left, `1` right, `2` both, `255` for a kind with no lane (both observation kinds). - channel: number; - /// Strip index: the control list's track owner rows first, then its submix owner rows, each in - /// list order (issue 1213). - trackIndex: number; - /// A console slot's index in the session's slot order, or an insert's index. - effectIndex: number; + /// The strip's ID: a track's or a submix's. + stripId: string; + /// The console slot's ID or the insert's ID. + effectId: string; /// The effect-local observation tap id. - parameterId: number; + tapId: number; /// The observation window length in render blocks, or `0` for the plan's default. - smoothingSamples: number; - /// Every word `0` for the observation kinds. - values: [number, number, number, number]; + windowBlocks: number; } export interface MisoCommandRequest { @@ -584,13 +591,13 @@ export interface MisoMeterFrame { /// unknown field with `RESULT_INVALID_ARGUMENT` (1) before anything reaches the port, so an /// optional-looking extra property is a local refusal rather than a field the engine ignores. export interface MisoObservationSubscription { - /// Strip index, as `MisoCommand.trackIndex`: tracks first, then submixes (issue 1213), so a - /// bus effect's tap is armed at `tracks.length + j`. - trackIndex: number; + /// The strip's ID, as `MisoCommand.stripId`: a track's or a submix's. + stripId: string; /// `1` inserts or `3` console, as `MisoCommand` addresses an effect. There is no `255` here: /// a tap always names a rack. rack: number; - effectIndex: number; + /// The console slot's ID or the insert's ID, as `MisoCommand.effectId`. + effectId: string; /// The effect-local tap id from the metadata JSON's per-effect `observations[].id`. Never `0`. /// /// This is a *tap* id, not a parameter id: they are different namespaces on one effect, and a @@ -617,16 +624,15 @@ export interface MisoObservationRequest { /// This is where "which tracks have an observed effect at all" lives. `trackGrDb` is positional /// and cannot express absence; this can. export interface MisoObservationBinding { - readonly trackIndex: number; + /// The strip's ID. The strip's gain-reduction entry in a meter frame is at its position in the + /// control list's strip order (tracks, then submixes). + readonly stripId: string; /// `1` inserts or `3` console. readonly rack: number; - readonly effectIndex: number; + /// The console slot's ID or the insert's ID. + readonly effectId: string; /// The effect-local tap id, matching the metadata JSON's per-effect `observations[].id`. readonly tapId: number; - /// The strip index of the gain-reduction entry this tap folds into, equal to `trackIndex` - /// (issue 1213). Below `MisoMeterFrame.trackCount` it indexes `trackGrDb`; from there it is - /// `submixGrDb[frameSlot - trackCount]`. - readonly frameSlot: number; /// Render blocks per published window, as it is actually in force. readonly windowBlocks: number; } @@ -645,18 +651,18 @@ export interface MisoObservationBinding { /// further `observe()` calls all keep working. Only a locally malformed request rejects, with /// `MisoError` and `result: 1`, before anything is sent. /// -/// # Subscriptions are per plan +/// # Subscriptions follow the key /// -/// A structural session edit replaces the plan, and subscriptions belong to the plan they were -/// applied to: the replacement's lanes exist and are unarmed, and the readers the old plan handed -/// out simply stop advancing. An app re-subscribes against the new plan and receives a fresh map -/// whose sequences restart at `1`. Nothing carries over, and nothing has to be torn down. +/// A tap is named by its stable key, so a structural session edit does not move it: the core keeps +/// the tap armed on the replacement plan while the strip, the effect and the tap still exist, and +/// announces a tap it ended with an `OBSERVATION_ENDED` event. `bindings` is this host's own record +/// of what it armed and withdrew; it does not drop a tap the engine ended. export interface MisoObservationAck { readonly tag: "miso.observe.v1"; readonly requestId: number; readonly result: number; readonly reason: MisoCommandReason; - /// Every armed tap of the current plan, in canonical `(track, rack, effectIndex, tapId)` order. + /// Every tap this host armed and has not withdrawn, in `(stripId, rack, effectId, tapId)` order. readonly bindings: MisoObservationBinding[]; } diff --git a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.js b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.js index 5d9adf040..b68bb1d52 100644 --- a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.js +++ b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.js @@ -89,8 +89,12 @@ const SOURCE_FIELDS = [ ]; const SEEK_FIELDS = ["sourceId", "generation", "sourceFrame"]; -// Issue #137 D1: the frozen 48-byte little-endian command record. -const COMMAND_RECORD_BYTES = 48; +// Issue #137 D1, #1677: the frozen 272-byte little-endian command record. It names its tap by +// stable key: a one-byte length and a 128-byte zero-padded field for each of the strip's and the +// effect's ID. The IDs are at most 127 UTF-8 bytes (`session::STABLE_ID_MAXIMUM_BYTES`). +const COMMAND_RECORD_BYTES = 272; +const RECORD_ID_FIELD_BYTES = 128; +const RECORD_ID_MAXIMUM_BYTES = RECORD_ID_FIELD_BYTES - 1; const MAXIMUM_COMMAND_RECORDS = 256; // Issue #1562a D1: the control frames in flight on the port at once. The worklet answers them in // arrival order; a flood is refused here with `RESULT_BACKPRESSURE` before any transfer. @@ -105,7 +109,7 @@ const MAXIMUM_OBSERVATION_TAPS = 16; const OBSERVE_SUBSCRIBE_KIND = 7; const OBSERVE_UNSUBSCRIBE_KIND = 8; const SUBSCRIPTION_FIELDS = [ - "trackIndex", "rack", "effectIndex", "tapId", "windowBlocks", "armed", + "stripId", "rack", "effectId", "tapId", "windowBlocks", "armed", ]; const OBSERVATION_ADDRESS_FIELDS = ["trackIndex", "rack", "effectIndex", "tapId", "channels"]; const MAXIMUM_OBSERVATION_READS = MAXIMUM_COMMAND_RECORDS; @@ -168,11 +172,27 @@ const RACK_INSERTS = 1; const RACK_CONSOLE = 3; const effectRack = (rack) => rack === RACK_INSERTS || rack === RACK_CONSOLE; +/// Order two IDs by their code units, which is their byte order for the ASCII IDs a session holds. +function compareIds(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +/// The UTF-8 bytes of a record's ID field. It runs on the caller's thread, never in `process()`. +function idBytes(id) { + return new TextEncoder().encode(id); +} + +/// A record's ID: a string whose UTF-8 form fits the 127 bytes of its field. Whether it names +/// anything is the engine's to say, with a typed reason. +function validRecordId(id) { + return typeof id === "string" && idBytes(id).length <= RECORD_ID_MAXIMUM_BYTES; +} + function validSubscription(subscription) { return hasExactFields(subscription, SUBSCRIPTION_FIELDS) - && validU32(subscription.trackIndex) + && validRecordId(subscription.stripId) && effectRack(subscription.rack) - && validU32(subscription.effectIndex) + && validRecordId(subscription.effectId) && validU32(subscription.tapId) && subscription.tapId > 0 && validU32(subscription.windowBlocks) && typeof subscription.armed === "boolean"; @@ -223,6 +243,10 @@ function validObservationRow(row) { // `hosts/host-web/src/lib.rs`'s `COMMAND_REASON_*` constants, the `.d.ts` // `MisoCommandReason` enum, the metadata generator's `commandReasons` rows and the schema // gate's own list. Bump a Rust reason without the rest and that gate is red. +// +// Reasons 12-14 belonged to the value kinds #1565A deleted. They are retired and never +// reallocated, so their entries are `null`: an acknowledgement that names one is malformed. +// Issue #1676 added 15, `observationLimit`. const COMMAND_REASONS = Object.freeze([ "none", "malformed", @@ -236,6 +260,10 @@ const COMMAND_REASONS = Object.freeze([ "wrongState", "unknownTap", "observationUnbound", + null, + null, + null, + "observationLimit", ]); /// `true` for exactly the reasons this ABI version declares. @@ -243,13 +271,10 @@ const COMMAND_REASONS = Object.freeze([ /// A reason outside the table is a genuinely malformed acknowledgement and still fails the host: /// the fix for #151 widened the vocabulary, it did not stop validating it. function validCommandReason(reason) { - return validU32(reason) && reason < COMMAND_REASONS.length; + return validU32(reason) && reason < COMMAND_REASONS.length && COMMAND_REASONS[reason] !== null; } -const COMMAND_FIELDS = [ - "kind", "rack", "channel", "trackIndex", "effectIndex", "parameterId", "smoothingSamples", - "values", -]; +const COMMAND_FIELDS = ["kind", "rack", "stripId", "effectId", "tapId", "windowBlocks"]; // Issue #143 added kinds 7 and 8, the two observation subscribe/unsubscribe records. Issue #1565a // deleted every value kind (1-6 and 9-17): a value goes through the `control` message, and a record // of a deleted kind is refused here as an unknown kind is. Kind numbers are never reallocated. @@ -258,36 +283,32 @@ const COMMAND_FIELDS = [ // the spellings `scripts/check-command-kind-vocabulary.py` holds to the Rust `COMMAND_*` // constants, so a kind that exists on the wire and not here is red before it ships. const COMMAND_KINDS = new Set([7, 8]); -const NOT_APPLICABLE = 255; function validCommand(command) { return hasExactFields(command, COMMAND_FIELDS) && COMMAND_KINDS.has(command.kind) - && (effectRack(command.rack) || command.rack === NOT_APPLICABLE) - && (validU32(command.channel) && (command.channel <= 2 || command.channel === NOT_APPLICABLE)) - && validU32(command.trackIndex) && validU32(command.effectIndex) - && validU32(command.parameterId) && validU32(command.smoothingSamples) - && Array.isArray(command.values) && command.values.length === 4 - && command.values.every((value) => typeof value === "number" && Number.isFinite(value)); + && effectRack(command.rack) + && validRecordId(command.stripId) && validRecordId(command.effectId) + && validU32(command.tapId) && validU32(command.windowBlocks); } -/// Encode one live-control submission into the existing 48-byte semantic wire record. +/// Encode one live-control submission into the 272-byte semantic wire record (#1677). function encodeCommands(commands) { const records = new Uint8Array(commands.length * COMMAND_RECORD_BYTES); const view = new DataView(records.buffer); for (let index = 0; index < commands.length; index += 1) { const command = commands[index]; const offset = index * COMMAND_RECORD_BYTES; + const stripId = idBytes(command.stripId); + const effectId = idBytes(command.effectId); records[offset] = command.kind; records[offset + 1] = command.rack; - records[offset + 2] = command.channel; - view.setUint32(offset + 4, command.trackIndex, true); - view.setUint32(offset + 8, command.effectIndex, true); - view.setUint32(offset + 12, command.parameterId, true); - view.setUint32(offset + 16, command.smoothingSamples, true); - for (let slot = 0; slot < 4; slot += 1) { - view.setFloat32(offset + 24 + slot * 4, command.values[slot], true); - } + records[offset + 2] = stripId.length; + records[offset + 3] = effectId.length; + view.setUint32(offset + 4, command.tapId, true); + view.setUint32(offset + 8, command.windowBlocks, true); + records.set(stripId, offset + 16); + records.set(effectId, offset + 16 + RECORD_ID_FIELD_BYTES); } return records; } @@ -1423,28 +1444,24 @@ class MisoAudioWorkletHost { ? OBSERVE_SUBSCRIBE_KIND : OBSERVE_UNSUBSCRIBE_KIND, rack: subscription.rack, - channel: 255, - trackIndex: subscription.trackIndex, - effectIndex: subscription.effectIndex, - parameterId: subscription.tapId, - smoothingSamples: subscription.windowBlocks, - values: [0, 0, 0, 0], + stripId: subscription.stripId, + effectId: subscription.effectId, + tapId: subscription.tapId, + windowBlocks: subscription.windowBlocks, })); const ack = await this.command({ commands }); if (ack.result === 0) { for (const subscription of request.subscriptions) { - const key = [ - subscription.trackIndex, subscription.rack, subscription.effectIndex, subscription.tapId, - ].join("/"); + // The tap's stable key, the one the core's `OBSERVATION_ENDED` names. + const key = JSON.stringify([ + subscription.stripId, subscription.rack, subscription.effectId, subscription.tapId, + ]); if (subscription.armed) { this.#observations.set(key, { - trackIndex: subscription.trackIndex, + stripId: subscription.stripId, rack: subscription.rack, - effectIndex: subscription.effectIndex, + effectId: subscription.effectId, tapId: subscription.tapId, - // The frame carries one gain-reduction entry per strip (trackGrDb, then submixGrDb), - // so the slot is the strip index (issue #1213). - frameSlot: subscription.trackIndex, windowBlocks: subscription.windowBlocks === 0 ? Number(this.#liveControlMeterBlocks) : subscription.windowBlocks, @@ -1460,9 +1477,9 @@ class MisoAudioWorkletHost { result: ack.result, reason: ack.reason, bindings: Object.freeze([...this.#observations.values()] - .sort((left, right) => left.trackIndex - right.trackIndex + .sort((left, right) => compareIds(left.stripId, right.stripId) || left.rack - right.rack - || left.effectIndex - right.effectIndex + || compareIds(left.effectId, right.effectId) || left.tapId - right.tapId) .map(Object.freeze)), }); @@ -1471,8 +1488,9 @@ class MisoAudioWorkletHost { /// Read the compiled session's canonical track order (issue #137 D1), its sources (issue #207) /// and its canonical submix order (issue #1210 D3). /// - /// This is the addressing authority for `trackIndex`: the app never guesses an index, and never - /// sends a string on the command path. + /// This is the addressing authority for the strip positions the meter frame and the observation + /// map use: the app never guesses an index. A command record does not use one: it names its tap + /// by stable key (#1677). sessionMap() { return this.#request({ tag: "miso.sessionmap.v1" }, [], "sessionMap", false, undefined, "sessionMap"); } diff --git a/hosts/host-web/web/miso-engine-v1-audio-worklet.js b/hosts/host-web/web/miso-engine-v1-audio-worklet.js index 4da234947..de024f4b0 100644 --- a/hosts/host-web/web/miso-engine-v1-audio-worklet.js +++ b/hosts/host-web/web/miso-engine-v1-audio-worklet.js @@ -26,7 +26,7 @@ const STATE_READY = 2; const PROCESSOR_NAME = "miso-engine-v1-audio-worklet"; // Issue #137 D1/D2/D3. -const COMMAND_RECORD_BYTES = 48; +const COMMAND_RECORD_BYTES = 272; const MAXIMUM_COMMAND_RECORDS = 256; const COMMAND_REPORT_BYTES = 48; const OBSERVATION_SELECTION_BYTES = 32; diff --git a/scripts/check-abi-layout-v1.py b/scripts/check-abi-layout-v1.py index 64304bf65..2d0eab483 100644 --- a/scripts/check-abi-layout-v1.py +++ b/scripts/check-abi-layout-v1.py @@ -81,10 +81,14 @@ # values instead of positional from 1. COMMAND_KINDS = [(7, "observeSubscribe"), (8, "observeUnsubscribe")] RETIRED_COMMAND_KINDS = set(range(1, 7)) | set(range(9, 18)) +# Reasons 12-14 belonged to the deleted value kinds (#1565A) and are retired, never reallocated; +# issue #1676 added `observationLimit` (15) after them. The values are explicit for that reason. COMMAND_REASONS = [ - "none", "malformed", "unknownTrack", "unknownRack", "unknownEffect", "unknownParameter", - "domain", "unsupportedKind", "backpressure", "wrongState", "unknownTap", "observationUnbound", + (0, "none"), (1, "malformed"), (2, "unknownTrack"), (3, "unknownRack"), (4, "unknownEffect"), + (5, "unknownParameter"), (6, "domain"), (7, "unsupportedKind"), (8, "backpressure"), + (9, "wrongState"), (10, "unknownTap"), (11, "observationUnbound"), (15, "observationLimit"), ] +RETIRED_COMMAND_REASONS = {12, 13, 14} RESPONSE_GRIDS = [(1, "linear"), (2, "logarithmic")] RESPONSE_CHANNELS = [(1, "left"), (2, "right"), (3, "both")] # Decision 12 (#1096): the track targets follow the tap rename; their codes are unchanged. @@ -257,8 +261,8 @@ ROLE_DEFINED_WORDS = ["requireSampleRateHz", "requireQuantumFrames"] COMMAND_RECORD_FIELDS = [ - "kind", "rack", "channel", "reserved0", "trackIndex", "effectIndex", "parameterId", - "smoothingSamples", "reserved1", "values", "reserved2", + "kind", "rack", "stripIdBytes", "effectIdBytes", "tapId", "windowBlocks", "reserved0", + "stripId", "effectId", ] # The applied-revision watermark record (#1381a D5): the C ABI's `miso_engine_v1_watermark`, field # for field and in its order (#1314 D6), without a pre-roll counter. @@ -382,14 +386,6 @@ def check_named(document: dict, group: str, expected: list[tuple[int, str]]) -> require(actual == expected, f"constants.{group} is {actual}, expected {expected}") -def check_positional(document: dict, group: str, names: list[str]) -> None: - """A vocabulary whose row position stands for its value, contiguous from 0.""" - rows = document["constants"].get(group) - require(isinstance(rows, list), f"constants.{group} is a list") - actual = [(row["value"], row["name"]) for row in rows] - require(actual == list(enumerate(names)), - f"constants.{group} is not the contiguous vocabulary {names}") - def validate(document: object) -> None: require(isinstance(document, dict), "the document is a JSON object") @@ -440,8 +436,8 @@ def validate(document: object) -> None: require(isinstance(record, dict) and set(record) == {"bytes", "endianness", "fields"}, "commandRecord keys are exactly bytes/endianness/fields") require(record["endianness"] == "little", "the command record is little-endian") - require(record["bytes"] == 48, f"the command record is 48 bytes, not {record['bytes']}") - check_fields("commandRecord", record["fields"], 48) + require(record["bytes"] == 272, f"the command record is 272 bytes, not {record['bytes']}") + check_fields("commandRecord", record["fields"], 272) require([row["name"] for row in record["fields"]] == COMMAND_RECORD_FIELDS, f"commandRecord names exactly {COMMAND_RECORD_FIELDS}") @@ -500,7 +496,11 @@ def validate(document: object) -> None: check_named(document, "wireCommandKinds", COMMAND_KINDS) require(not RETIRED_COMMAND_KINDS & {row["value"] for row in constants["wireCommandKinds"]}, "constants.wireCommandKinds reallocates a retired value-command number") - check_positional(document, "commandReasons", COMMAND_REASONS) + check_named(document, "commandReasons", COMMAND_REASONS) + reason_values = [row["value"] for row in constants["commandReasons"]] + require(reason_values == [value for value in range(max(reason_values, default=-1) + 1) + if value not in RETIRED_COMMAND_REASONS], + "constants.commandReasons is not dense from 0 except the retired 12-14") check_named(document, "responseGrids", RESPONSE_GRIDS) check_named(document, "responseChannels", RESPONSE_CHANNELS) check_named(document, "spectrumTargets", SPECTRUM_TARGETS) @@ -713,6 +713,11 @@ def boolean_named_value(document: dict) -> None: ("a retired rack code is reallocated to console", reallocate_retired_rack), ("a retired owner-record rack code returns", retired_owner_rack_returns), ("a spectrum target keeps its pre-rename spelling", stale_spectrum_target), + # Issue #1676: `observationLimit` is 15, after the retired 12-14. + ("observationLimit reallocates a retired reason number", + lambda d: d["constants"]["commandReasons"][-1].update(value=12)), + ("the reason vocabulary drops observationLimit", + lambda d: d["constants"]["commandReasons"].pop()), ] for name, mutate in mutations: broken = copy.deepcopy(sample) diff --git a/scripts/check-command-kind-vocabulary.py b/scripts/check-command-kind-vocabulary.py index 7d5fc6e59..8434061c6 100755 --- a/scripts/check-command-kind-vocabulary.py +++ b/scripts/check-command-kind-vocabulary.py @@ -7,7 +7,7 @@ * `hosts/host-web/src/lib.rs` -- the `COMMAND_*` constants. This is the authority. * the same file's `CommandRecord::decode` whitelist -- the `matches!` arm that decides what the - 48-byte wire actually accepts. A constant the whitelist does not name is a kind no caller can + command-record wire actually accepts. A constant the whitelist does not name is a kind no caller can send; a whitelist entry the constants do not name cannot exist. * `hosts/host-web/web/miso-engine-v1-audio-worklet-host.js` -- the `COMMAND_KINDS` set `validCommand` gates every submitted record through. @@ -150,7 +150,7 @@ def rust_constants(text: str) -> list[tuple[int, str]]: def rust_decode_whitelist(text: str) -> list[str]: - """The `matches!` arm in `CommandRecord::decode` -- what the 48-byte wire actually accepts.""" + """The `matches!` arm in `CommandRecord::decode` -- what the command-record wire actually accepts.""" block = block_after(text, "let kind = u32::from(bytes[0]);", "(", ")") names = [ camel_from_screaming(name) diff --git a/scripts/check-command-reason-vocabulary.py b/scripts/check-command-reason-vocabulary.py index 225d1908c..7d72614e0 100644 --- a/scripts/check-command-reason-vocabulary.py +++ b/scripts/check-command-reason-vocabulary.py @@ -18,8 +18,12 @@ * `scripts/check-parameter-metadata-v1.py` -- the schema gate's deliberately independent list. Adding a reason to the Rust constants without the other five is the drift class. This gate makes -that red: every source must yield the same ordered `value -> camelCase name` mapping, contiguous -from `0`. +that red: every source must yield the same ordered `value -> camelCase name` mapping, dense from +`0` except for the retired reasons 12-14. Those belonged to the value kinds #1565A deleted; they +are never reallocated, so the declared values are exactly `range(0, max + 1)` minus the retired set +and issue #1676's `observationLimit` is 15. A spelling whose row position stands for its value (the +host JS table) holds `null` at each retired value, and a positional list without holes (the schema +gate's) maps its n-th name to the n-th value that is not retired. Issue #151's other half is structural rather than numeric, and is checked here too: the shipped `.d.ts` must declare `observe()` and the `miso.observe.v1` subscription-map types, with field sets @@ -50,6 +54,10 @@ METADATA_DOCUMENT = "miso-engine-v1-parameter-metadata.json" +# Reasons 12-14 (`notSoloable`, `unknownRoute`, `unknownVca`) were the deleted value kinds' (#1565A). +# Retired: never a declared value again, and the one gap the vocabulary may have. +RETIRED_REASONS = frozenset({12, 13, 14}) + class Invalid(Exception): """One rule this gate enforces was broken.""" @@ -85,6 +93,24 @@ def balanced(text: str, start: int, opening: str, closing: str) -> str: raise Invalid(f"unbalanced {opening!r} from offset {start}") +def dense_values(values: list[int]) -> bool: + """The values are exactly `range(0, max + 1)` minus the retired set, in order.""" + if not values: + return False + return values == [value for value in range(max(values) + 1) if value not in RETIRED_REASONS] + + +def vocabulary_values(count: int) -> list[int]: + """The first `count` values the vocabulary may declare: 0, 1, ... skipping the retired ones.""" + values: list[int] = [] + value = 0 + while len(values) < count: + if value not in RETIRED_REASONS: + values.append(value) + value += 1 + return values + + def block_after(text: str, anchor: str, opening: str, closing: str) -> str: """The bracketed block that follows `anchor`.""" at = text.find(anchor) @@ -108,10 +134,16 @@ def rust_constants(text: str) -> list[tuple[int, str]]: def host_js_table(text: str) -> list[tuple[int, str]]: + """The table is indexed by value: a name, or `null` at a retired value.""" block = block_after(text, "const COMMAND_REASONS = Object.freeze(", "[", "]") - names = re.findall(r'"([A-Za-z][A-Za-z0-9]*)"', block) - require(names, "the host JS COMMAND_REASONS table is empty") - return list(enumerate(names)) + entries = re.findall(r'"([A-Za-z][A-Za-z0-9]*)"|\bnull\b', block) + require(any(entries), "the host JS COMMAND_REASONS table is empty") + holes = [index for index, name in enumerate(entries) if not name] + require( + set(holes) <= RETIRED_REASONS, + f"the host JS COMMAND_REASONS table has a hole at a value that is not retired: {holes}", + ) + return [(index, name) for index, name in enumerate(entries) if name] def host_dts_enum(text: str) -> list[tuple[int, str]]: @@ -124,7 +156,8 @@ def host_dts_enum(text: str) -> list[tuple[int, str]]: return sorted(rows) -def metadata_generator_table(text: str) -> list[tuple[int, str]]: +def metadata_generator_table(text: str, constants: dict[str, int]) -> list[tuple[int, str]]: + """Each row emits its constant's value, so the value is the Rust constant's.""" block = block_after(text, "let reasons = ", "[", "]") names = [ camel_from_screaming(constant) @@ -139,14 +172,23 @@ def metadata_generator_table(text: str) -> list[tuple[int, str]]: "a metadata generator row emits a name that is not its own constant's name: " f"{[pair for pair in zip(names, emitted) if pair[0] != pair[1]]}", ) - return list(enumerate(names)) + missing = [name for name in names if name not in constants] + require(not missing, f"a metadata generator row names no Rust constant: {missing}") + return [(constants[name], name) for name in names] def schema_gate_list(text: str) -> list[tuple[int, str]]: block = block_after(text, "\nCOMMAND_REASONS = ", "[", "]") names = re.findall(r'"([A-Za-z][A-Za-z0-9]*)"', block) require(names, "the schema gate's COMMAND_REASONS list is empty") - return list(enumerate(names)) + return list(zip(vocabulary_values(len(names)), names)) + + +def schema_gate_retired(text: str) -> set[int]: + """The schema gate's own retired set, which must be this gate's.""" + match = re.search(r"^RETIRED_COMMAND_REASONS = \{([0-9, ]*)\}$", text, re.MULTILINE) + require(match, "the schema gate declares no RETIRED_COMMAND_REASONS set") + return {int(value) for value in match.group(1).split(",") if value.strip()} def worklet_js_constants(text: str) -> list[tuple[int, str]]: @@ -258,21 +300,28 @@ def check_no_literal_bound(js: str) -> None: ) derived = re.search( r"function validCommandReason\(reason\) \{\s*return validU32\(reason\)" - r" && reason < COMMAND_REASONS\.length;", + r" && reason < COMMAND_REASONS\.length && COMMAND_REASONS\[reason\] !== null;", js, ) - require(derived, "validCommandReason() no longer derives its bound from COMMAND_REASONS") + require( + derived, + "validCommandReason() no longer derives its bound from COMMAND_REASONS, or accepts a " + "retired reason's null entry", + ) # --- the gate ----------------------------------------------------------------------------------- def validate(texts: dict[pathlib.Path, str], document: dict | None = None) -> None: + rust = rust_constants(texts[RUST_CONSTANTS]) spellings = { - "rust host constants": rust_constants(texts[RUST_CONSTANTS]), + "rust host constants": rust, "host JS table": host_js_table(texts[HOST_JS]), ".d.ts MisoCommandReason": host_dts_enum(texts[HOST_DTS]), - "metadata generator rows": metadata_generator_table(texts[METADATA_GENERATOR]), + "metadata generator rows": metadata_generator_table( + texts[METADATA_GENERATOR], {name: value for value, name in rust} + ), "schema gate list": schema_gate_list(texts[SCHEMA_GATE]), } if document is not None: @@ -280,8 +329,13 @@ def validate(texts: dict[pathlib.Path, str], document: dict | None = None) -> No authority = spellings["rust host constants"] require( - [value for value, _ in authority] == list(range(len(authority))), - f"the Rust reason constants are not contiguous from 0: {authority}", + dense_values([value for value, _ in authority]), + "the Rust reason constants are not dense from 0 except the retired " + f"{sorted(RETIRED_REASONS)}: {authority}", + ) + require( + schema_gate_retired(texts[SCHEMA_GATE]) == RETIRED_REASONS, + "the schema gate's RETIRED_COMMAND_REASONS is not this gate's retired set", ) for name, rows in spellings.items(): require( @@ -329,7 +383,7 @@ def apply(state: dict[pathlib.Path, str]) -> None: RUST_CONSTANTS, "pub const COMMAND_REASON_OBSERVATION_UNBOUND: u32 = 11;", "pub const COMMAND_REASON_OBSERVATION_UNBOUND: u32 = 11;\n" - "pub const COMMAND_REASON_FUTURE_TAP: u32 = 15;", + "pub const COMMAND_REASON_FUTURE_TAP: u32 = 16;", ), ), # Reasons 12-14 belonged to the deleted value kinds (#1565A); a constant that brings one @@ -348,7 +402,7 @@ def apply(state: dict[pathlib.Path, str]) -> None: mutate( RUST_CONSTANTS, "pub const COMMAND_REASON_UNKNOWN_TAP: u32 = 10;", - "pub const COMMAND_REASON_UNKNOWN_TAP: u32 = 15;", + "pub const COMMAND_REASON_UNKNOWN_TAP: u32 = 16;", ), ), # The shipped #151 defect itself, in each of the five downstream spellings. @@ -372,8 +426,65 @@ def apply(state: dict[pathlib.Path, str]) -> None: "the host JS bound stops deriving from the table", mutate( HOST_JS, - "reason < COMMAND_REASONS.length;", - "reason <= 11;", + "reason < COMMAND_REASONS.length &&", + "reason <= 15 &&", + ), + ), + # Issue #1676: the retired gap, in each spelling that can express it. + ( + "the host JS accepts a retired reason's null entry", + mutate(HOST_JS, " && COMMAND_REASONS[reason] !== null;", ";"), + ), + ( + "the host JS table drops a retired hole, so observationLimit reads as 14", + mutate(HOST_JS, " null,\n \"observationLimit\",", " \"observationLimit\","), + ), + ( + "the host JS table holds a hole at a value that is not retired", + mutate(HOST_JS, ' "observationUnbound",\n', " null,\n"), + ), + ( + "the Rust observationLimit reallocates retired 12 without the other spellings", + mutate( + RUST_CONSTANTS, + "pub const COMMAND_REASON_OBSERVATION_LIMIT: u32 = 15;", + "pub const COMMAND_REASON_OBSERVATION_LIMIT: u32 = 12;", + ), + ), + ( + "the Rust observationLimit leaves a gap other than the retired one", + mutate( + RUST_CONSTANTS, + "pub const COMMAND_REASON_OBSERVATION_LIMIT: u32 = 15;", + "pub const COMMAND_REASON_OBSERVATION_LIMIT: u32 = 16;", + ), + ), + ( + "the .d.ts enum drops ObservationLimit", + mutate(HOST_DTS, " ObservationLimit = 15,\n", ""), + ), + ( + "the .d.ts enum puts ObservationLimit on a retired value", + mutate(HOST_DTS, " ObservationLimit = 15,", " ObservationLimit = 12,"), + ), + ( + "the metadata generator drops observationLimit", + mutate( + METADATA_GENERATOR, + ' (COMMAND_REASON_OBSERVATION_LIMIT, "observationLimit"),\n', + "", + ), + ), + ( + "the schema gate's list drops observationLimit", + mutate(SCHEMA_GATE, '\n "observationLimit",\n', "\n"), + ), + ( + "the schema gate's retired set forgets 14", + mutate( + SCHEMA_GATE, + "RETIRED_COMMAND_REASONS = {12, 13, 14}", + "RETIRED_COMMAND_REASONS = {12, 13}", ), ), ( @@ -462,24 +573,44 @@ def apply(state: dict[pathlib.Path, str]) -> None: ), ), ( - "the declared binding drops frameSlot", - mutate(HOST_DTS, " readonly frameSlot: number;\n", ""), + "the declared binding drops windowBlocks", + mutate( + HOST_DTS, + " /// Render blocks per published window, as it is actually in force.\n readonly windowBlocks: number;\n}", + "}", + ), ), ( "the declared acknowledgement drops reason", - mutate(HOST_DTS, " readonly reason: MisoCommandReason;\n /// Every armed tap", " /// Every armed tap"), + mutate(HOST_DTS, " readonly reason: MisoCommandReason;\n /// Every tap this host armed", " /// Every tap this host armed"), ), ( "the implementation gains a binding field the .d.ts does not declare", mutate( HOST_JS, - " frameSlot: subscription.trackIndex,", - " frameSlot: subscription.trackIndex,\n armed: true,", + " effectId: subscription.effectId,\n tapId: subscription.tapId,\n windowBlocks:", + " effectId: subscription.effectId,\n tapId: subscription.tapId,\n armed: true,\n windowBlocks:", ), ), ] + # The dense rule itself (#1676 root ruling): 0..11 then 15 passes; a reallocated retired value, + # a gap other than the retired one and a missing reason are each refused. + rule_cases = [ + ("0..11 then 15", list(range(12)) + [15], True), + ("0..11 only", list(range(12)), True), + ("12 reallocated", list(range(13)), False), + ("14 reallocated before 15", list(range(12)) + [14, 15], False), + ("a gap at 16", list(range(12)) + [16], False), + ("15 then a gap at 17", list(range(12)) + [15, 17], False), + ("11 missing", list(range(11)) + [15], False), + ("out of order", list(range(11)) + [15, 11], False), + ] failures = 0 + for name, values, expected in rule_cases: + if dense_values(values) != expected: + print(f"self-test FAILED: the dense rule misjudges {name}", file=sys.stderr) + failures += 1 for name, apply in mutations: state = dict(texts) try: @@ -497,7 +628,10 @@ def apply(state: dict[pathlib.Path, str]) -> None: print(f"self-test FAILED: mutation escaped -- {name}", file=sys.stderr) failures += 1 if failures == 0: - print(f"command-reason vocabulary self-test passed ({len(mutations)} red mutations)") + print( + f"command-reason vocabulary self-test passed ({len(mutations)} red mutations, " + f"{len(rule_cases)} dense-rule cases)" + ) return failures diff --git a/scripts/check-effect-runtime-policy.sh b/scripts/check-effect-runtime-policy.sh index 389f9c3c2..396219086 100644 --- a/scripts/check-effect-runtime-policy.sh +++ b/scripts/check-effect-runtime-policy.sh @@ -58,7 +58,6 @@ fn\s+flushed\( 0 95 fn\s+recover\( 0 95 struct\s+Ramp\b 0 95 fn\s+valid_runtime_span\( 1 95 -fn\s+automation_segment_value\( 1 95 fn\s+canonical_bits\( 1 95 struct\s+ParameterSmoother\b 1 95 struct\s+LinearRamp\b 1 95 diff --git a/scripts/check-parameter-metadata-v1.py b/scripts/check-parameter-metadata-v1.py index 8f529460a..4bc6e0939 100644 --- a/scripts/check-parameter-metadata-v1.py +++ b/scripts/check-parameter-metadata-v1.py @@ -27,7 +27,8 @@ UNITS = {1: "db", 2: "hz", 3: "milliseconds", 4: "samples", 5: "linear", 6: "ratio"} DOMAINS = {1: "continuous", 2: "boolean", 3: "enumeration"} MAPPINGS = {1: "linear", 2: "logarithmic", 3: "exponential", 4: "stepped"} -RATES = {1: "sample", 2: "block", 3: "none"} +# Rate 1 (`sample`) is retired (#1636): refused here, never reallocated. +RATES = {2: "block", 3: "none"} POLICIES = {1: "shared", 2: "perLane"} SMOOTHINGS = {1: "none", 2: "linear", 3: "onePole99"} @@ -52,13 +53,27 @@ # The list is spelled here rather than imported on purpose -- this validator is deliberately a # second implementation -- and `scripts/check-command-reason-vocabulary.py` is what proves the two # spellings, the Rust constants, the host JS table and the `.d.ts` enum are one vocabulary. +# Issue #1676 added `observationLimit` (15), after the retired gap. COMMAND_REASONS = [ "none", "malformed", "unknownTrack", "unknownRack", "unknownEffect", "unknownParameter", "domain", "unsupportedKind", "backpressure", "wrongState", "unknownTap", "observationUnbound", + "observationLimit", ] # Reasons 12-14 (`notSoloable`, `unknownRoute`, `unknownVca`) belonged to the deleted value kinds and # are retired with them, never reallocated. RETIRED_COMMAND_REASONS = {12, 13, 14} + + +def dense_reason_values(values: list[int]) -> bool: + """The declared values are exactly `range(0, max + 1)` minus the retired set, in order. + + So a reallocated retired value, a gap other than the retired one and a missing reason are all + refused, and the next reason after 11 is 15. + """ + if not values: + return False + expected = [value for value in range(max(values) + 1) if value not in RETIRED_COMMAND_REASONS] + return values == expected BUILTIN_UPDATE_RATES = {"preparedOnly", "blockTarget"} BUILTIN_SCOPES = {"perLane", "matrixShared"} BUILTIN_MAPPINGS = {"boolean", "decibelAmplitude", "hertz", "linear"} @@ -208,7 +223,7 @@ def validate(document: dict) -> None: }, "controls keys") require(document["schema"] == SCHEMA, "schema tag") require(document["abiVersion"] == ABI_VERSION, "abi version") - require(document["commandRecordBytes"] == 48, "command record bytes") + require(document["commandRecordBytes"] == 272, "command record bytes") require(document["maximumCommandRecords"] >= 1, "maximum command records") kinds = document["commandKinds"] @@ -248,8 +263,8 @@ def validate(document: dict) -> None: reasons = document["commandReasons"] require([reason["name"] for reason in reasons] == COMMAND_REASONS, "command reasons") require( - [reason["value"] for reason in reasons] == list(range(len(COMMAND_REASONS))), - "command reason values", + dense_reason_values([reason["value"] for reason in reasons]), + "command reason values are dense from 0 except the retired 12-14", ) require( not RETIRED_COMMAND_REASONS & {reason["value"] for reason in reasons}, @@ -665,11 +680,25 @@ def self_test() -> int: slice(10, None) )), ("reason renamed", lambda d: d["commandReasons"][10].update(name="unknownObservation")), - ("reason value renumbered", lambda d: d["commandReasons"][11].update(value=15)), + ("reason value renumbered", lambda d: d["commandReasons"][11].update(value=16)), ( "a retired reason returns", lambda d: d["commandReasons"].append({"value": 12, "name": "notSoloable"}), ), + # Issue #1676: 15 follows the retired gap. Each way of breaking "dense except 12-14". + ( + "observationLimit reallocates a retired value", + lambda d: d["commandReasons"][12].update(value=12), + ), + ( + "a gap other than the retired one", + lambda d: d["commandReasons"][12].update(value=16), + ), + ("the newest reason is missing", lambda d: d["commandReasons"].pop(12)), + ( + "an older reason is missing before the gap", + lambda d: d["commandReasons"].pop(11), + ), ( "builtin live disagrees with update rate", lambda d: d["builtins"]["parameters"][6].update(liveUpdatable=False), @@ -717,6 +746,12 @@ def self_test() -> int: # Issue #242 replaced #127's null slot with a populated lattice declaration. Each # mutation below breaks exactly one sentence of that contract. ("builtin step slot is null", lambda d: d["builtins"]["parameters"][0].update(step=None)), + ( + "the retired sample automation rate returns (#1636)", + lambda d: d["effects"][0]["parameters"][0].update( + automationRate=1, automationRateName="sample" + ), + ), ("effect step slot is null", lambda d: d["effects"][0]["parameters"][0].update(step=None)), ( "step size is a float rather than decimal text", diff --git a/scripts/check-protocol-wasm-parity.sh b/scripts/check-protocol-wasm-parity.sh index 514e04c99..3360955f2 100755 --- a/scripts/check-protocol-wasm-parity.sh +++ b/scripts/check-protocol-wasm-parity.sh @@ -169,8 +169,8 @@ PY ) local -a files=("$PIN_FILE" "$PIN_FILE" "$GUEST_FILE") local -a searches=( - 'COMPLETE_SCHEMA_HASH: u64 = 0xcc9a_7bcc_502c_4936;' - 'COMPLETE_SCHEMA_HASH: u64 = 0xcc9a_7bcc_502c_4936;' + 'COMPLETE_SCHEMA_HASH: u64 = 0x0c71_96f8_6abe_6d57;' + 'COMPLETE_SCHEMA_HASH: u64 = 0x0c71_96f8_6abe_6d57;' 'assert_eq!(corpus.len(), 46);' ) local -a replacements=( diff --git a/scripts/fixtures/abi-layout-v1-self-test.json b/scripts/fixtures/abi-layout-v1-self-test.json index 0c685ce92..24cd35f74 100644 --- a/scripts/fixtures/abi-layout-v1-self-test.json +++ b/scripts/fixtures/abi-layout-v1-self-test.json @@ -435,20 +435,18 @@ } }, "commandRecord": { - "bytes": 48, + "bytes": 272, "endianness": "little", "fields": [ { "name": "kind", "offset": 0, "type": "u8" }, { "name": "rack", "offset": 1, "type": "u8" }, - { "name": "channel", "offset": 2, "type": "u8" }, - { "name": "reserved0", "offset": 3, "type": "u8" }, - { "name": "trackIndex", "offset": 4, "type": "u32" }, - { "name": "effectIndex", "offset": 8, "type": "u32" }, - { "name": "parameterId", "offset": 12, "type": "u32" }, - { "name": "smoothingSamples", "offset": 16, "type": "u32" }, - { "name": "reserved1", "offset": 20, "type": "u32" }, - { "name": "values", "offset": 24, "type": "f32[4]" }, - { "name": "reserved2", "offset": 40, "type": "u8[8]" } + { "name": "stripIdBytes", "offset": 2, "type": "u8" }, + { "name": "effectIdBytes", "offset": 3, "type": "u8" }, + { "name": "tapId", "offset": 4, "type": "u32" }, + { "name": "windowBlocks", "offset": 8, "type": "u32" }, + { "name": "reserved0", "offset": 12, "type": "u32" }, + { "name": "stripId", "offset": 16, "type": "u8[128]" }, + { "name": "effectId", "offset": 144, "type": "u8[128]" } ] }, "constants": { @@ -458,7 +456,7 @@ "backends": [{ "value": 0, "name": "scalar" }, { "value": 1, "name": "simd128" }], "bufferKinds": [{ "value": 2, "name": "sourceId" }, { "value": 3, "name": "sourcePcm" }, { "value": 4, "name": "diagnostic" }, { "value": 5, "name": "outputPcm" }, { "value": 6, "name": "command" }, { "value": 7, "name": "meterFrame" }], "wireCommandKinds": [{ "value": 7, "name": "observeSubscribe" }, { "value": 8, "name": "observeUnsubscribe" }], - "commandReasons": [{ "value": 0, "name": "none" }, { "value": 1, "name": "malformed" }, { "value": 2, "name": "unknownTrack" }, { "value": 3, "name": "unknownRack" }, { "value": 4, "name": "unknownEffect" }, { "value": 5, "name": "unknownParameter" }, { "value": 6, "name": "domain" }, { "value": 7, "name": "unsupportedKind" }, { "value": 8, "name": "backpressure" }, { "value": 9, "name": "wrongState" }, { "value": 10, "name": "unknownTap" }, { "value": 11, "name": "observationUnbound" }], + "commandReasons": [{ "value": 0, "name": "none" }, { "value": 1, "name": "malformed" }, { "value": 2, "name": "unknownTrack" }, { "value": 3, "name": "unknownRack" }, { "value": 4, "name": "unknownEffect" }, { "value": 5, "name": "unknownParameter" }, { "value": 6, "name": "domain" }, { "value": 7, "name": "unsupportedKind" }, { "value": 8, "name": "backpressure" }, { "value": 9, "name": "wrongState" }, { "value": 10, "name": "unknownTap" }, { "value": 11, "name": "observationUnbound" }, { "value": 15, "name": "observationLimit" }], "racks": [{ "value": 1, "name": "inserts" }, { "value": 3, "name": "console" }, { "value": 255, "name": "notApplicable" }], "liveResponseRacks": [{ "value": 0, "name": "inputFilters" }, { "value": 2, "name": "inserts" }, { "value": 4, "name": "console" }], "observationChannels": [{ "value": 1, "name": "left" }, { "value": 2, "name": "right" }, { "value": 3, "name": "both" }], diff --git a/scripts/fixtures/parameter-metadata-v1-self-test.json b/scripts/fixtures/parameter-metadata-v1-self-test.json index 2ed0fa5a3..07d22f6d8 100644 --- a/scripts/fixtures/parameter-metadata-v1-self-test.json +++ b/scripts/fixtures/parameter-metadata-v1-self-test.json @@ -1,7 +1,7 @@ { "schema": "miso.web.parameter-metadata.v1", "abiVersion": 65536, - "commandRecordBytes": 48, + "commandRecordBytes": 272, "maximumCommandRecords": 256, "commandKinds": [ { "value": 7, "name": "observeSubscribe", "applied": true, "plane": "observation" }, @@ -19,7 +19,8 @@ { "value": 8, "name": "backpressure" }, { "value": 9, "name": "wrongState" }, { "value": 10, "name": "unknownTap" }, - { "value": 11, "name": "observationUnbound" } + { "value": 11, "name": "observationUnbound" }, + { "value": 15, "name": "observationLimit" } ], "observationVocabularies": { "kinds": [{ "value": 1, "name": "gainReductionDb" }], diff --git a/scripts/test-web-audioworklet.mjs b/scripts/test-web-audioworklet.mjs index 2cc73cdba..d5b822213 100644 --- a/scripts/test-web-audioworklet.mjs +++ b/scripts/test-web-audioworklet.mjs @@ -1154,8 +1154,7 @@ async function testMainRealm() { // Issue #1565: the in-flight bound is the fixed `maximumCommandRecords` (256), not a boot // word, so 256 commands hold and the 257th is refused locally. const localCommand = { - kind: 7, rack: 1, channel: 255, trackIndex: 0, effectIndex: 0, parameterId: 1, - smoothingSamples: 0, values: [0, 0, 0, 0], + kind: 7, rack: 1, stripId: "track-0", effectId: "comp", tapId: 1, windowBlocks: 0, }; holdAll = true; const heldCommands = Array.from({ length: 256 }, () => @@ -1171,7 +1170,7 @@ async function testMainRealm() { "command capacity refusal did not burn an ID"); const observeAck = await hostValue.observe({ subscriptions: [{ - trackIndex: 0, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 0, armed: true, + stripId: "track-0", rack: 1, effectId: "comp", tapId: 1, windowBlocks: 0, armed: true, }], }); assert.equal(observeAck.result, 0, "captured observation default command accepted"); @@ -1883,8 +1882,7 @@ async function testMainRealm() { // Issue #1565: the only kinds `command()` carries are the two observation kinds. const sub = { - kind: 7, rack: 1, channel: 255, trackIndex: 1, effectIndex: 0, parameterId: 1, - smoothingSamples: 64, values: [0, 0, 0, 0], + kind: 7, rack: 1, stripId: "track-1", effectId: "comp", tapId: 1, windowBlocks: 64, }; // Issue #393: one real host fixture owns every request ID across interleaved request classes. // Twenty-five rounds cover 250 successful calls, including both lease orderings around a @@ -1892,7 +1890,7 @@ async function testMainRealm() { const mixedAcks = []; const mixedSendStart = events.length; const mixedSubscription = { - trackIndex: 0, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 1, armed: true, + stripId: "track-0", rack: 1, effectId: "comp", tapId: 1, windowBlocks: 1, armed: true, }; for (let round = 0; round < 25; round += 1) { mixedAcks.push((await liveControlHost.command({ commands: [sub] })).requestId); @@ -1957,7 +1955,7 @@ async function testMainRealm() { // Issue #1565: the host's own command path carries observation records only, so the SDK edit // that rides it is an observation subscription; a value edit goes through `control()`. const sdkReport = await browserLiveControls.submit( - browserLiveControls.edit.track("kick").effect("console", 0, "miso.compressor") + browserLiveControls.edit.track("kick").console("comp", "miso.compressor") .observe("Gain Reduction", true, 1), ); assert.equal(sdkReport.ok, true, "SDK live controls submit through the real host after direct meters"); @@ -1997,7 +1995,7 @@ async function testMainRealm() { assert.equal(commandAck.appliedAtSample, 512n, "the ack names the exact application sample"); const staged = events.findLast((event) => event[1] === "miso.command.v1"); assert.equal(staged[3], 1, "the record block is transferred, never copied"); - assert.equal(commandAck.records.byteLength, 48, "the block comes straight back to the caller"); + assert.equal(commandAck.records.byteLength, 272, "the block comes straight back to the caller"); const decoded = new DataView( commandAck.records.buffer, commandAck.records.byteOffset, @@ -2005,10 +2003,15 @@ async function testMainRealm() { ); assert.equal(decoded.getUint8(0), 7, "kind"); assert.equal(decoded.getUint8(1), 1, "rack"); - assert.equal(decoded.getUint32(4, true), 1, "track index"); - assert.equal(decoded.getUint32(12, true), 1, "tap id"); - assert.equal(decoded.getUint32(16, true), 64, "window blocks"); - assert.equal(decoded.getFloat32(24, true), 0, "value0"); + assert.equal(decoded.getUint8(2), 7, "strip ID length"); + assert.equal(decoded.getUint8(3), 4, "effect ID length"); + assert.equal(decoded.getUint32(4, true), 1, "tap id"); + assert.equal(decoded.getUint32(8, true), 64, "window blocks"); + const idText = (offset, length) => new TextDecoder().decode( + new Uint8Array(commandAck.records.buffer, commandAck.records.byteOffset + offset, length)); + assert.equal(idText(16, 7), "track-1", "strip ID"); + assert.equal(idText(16 + 128, 4), "comp", "effect ID"); + assert.equal(decoded.getUint8(16 + 7), 0, "the strip ID field is zero padded"); // A malformed command never reaches the port. const beforeMalformed = events.length; @@ -2020,21 +2023,28 @@ async function testMainRealm() { for (const kind of [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14, 15, 16, 17]) { await errorResult(liveControlHost.command({ commands: [{ ...sub, kind }] }), 1); } + // An ID that does not fit its 127-byte field, or is not a string, never reaches the port. + for (const broken of [ + { stripId: "x".repeat(128) }, { effectId: "é".repeat(64) }, { stripId: 3 }, { effectId: null }, + { tapId: -1 }, { windowBlocks: 1.5 }, { channel: 255 }, { trackIndex: 0 }, + ]) { + await errorResult(liveControlHost.command({ commands: [{ ...sub, ...broken }] }), 1); + } await errorResult( - liveControlHost.command({ commands: [{ ...sub, values: [0, 0, 0, NaN] }] }), + liveControlHost.command({ commands: [{ kind: 7, rack: 1, stripId: "a", effectId: "b", tapId: 1 }] }), 1, ); await errorResult(liveControlHost.command({ commands: [] }), 1); // The retired `simd1`/`simd2` rack codes and an unallocated one never reach the port (#1096). - for (const rack of [0, 2, 4]) { + for (const rack of [0, 2, 4, 255]) { await errorResult( - liveControlHost.command({ commands: [{ ...sub, rack, effectIndex: 0 }] }), + liveControlHost.command({ commands: [{ ...sub, rack }] }), 1, ); } await localErrorResult(liveControlHost.command({ requestId: 999, commands: [sub] }), 1); await localErrorResult(liveControlHost.observe({ requestId: 999, subscriptions: [{ - trackIndex: 0, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 1, armed: true, + stripId: "track-0", rack: 1, effectId: "comp", tapId: 1, windowBlocks: 1, armed: true, }] }), 1); const oldShapeBuffer = new ArrayBuffer(256); await localErrorResult(liveControlHost.submitSource({ @@ -2209,53 +2219,40 @@ async function testMainRealm() { // removes exactly one entry. const observeAck = await liveControlHost.observe({ subscriptions: [ - { trackIndex: 1, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 0, armed: true }, - { trackIndex: 0, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 8, armed: true }, + { stripId: "track-1", rack: 1, effectId: "comp", tapId: 1, windowBlocks: 0, armed: true }, + { stripId: "track-0", rack: 1, effectId: "comp", tapId: 1, windowBlocks: 8, armed: true }, ], }); assert.equal(observeAck.tag, "miso.observe.v1"); assert.equal(observeAck.result, 0); - assert.deepEqual(observeAck.bindings.map((binding) => binding.trackIndex), [0, 1], - "the map is canonically ordered by (track, rack, effectIndex, tapId)"); + assert.deepEqual(observeAck.bindings.map((binding) => binding.stripId), ["track-0", "track-1"], + "the map is canonically ordered by (stripId, rack, effectId, tapId)"); assert.deepEqual(observeAck.bindings[0], { - trackIndex: 0, rack: 1, effectIndex: 0, tapId: 1, frameSlot: 0, windowBlocks: 8, + stripId: "track-0", rack: 1, effectId: "comp", tapId: 1, windowBlocks: 8, }); assert.equal(observeAck.bindings[1].windowBlocks, Number(limits.liveControlMeterBlocks), "`windowBlocks: 0` resolves to the plan default, and the map says which one it got"); assert.equal(Object.isFrozen(observeAck.bindings), true); const unsubscribed = await liveControlHost.observe({ subscriptions: [ - { trackIndex: 1, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 0, armed: false }, + { stripId: "track-1", rack: 1, effectId: "comp", tapId: 1, windowBlocks: 0, armed: false }, ], }); assert.equal(unsubscribed.bindings.length, 1, "an unsubscribe removes exactly one entry"); - assert.equal(unsubscribed.bindings[0].trackIndex, 0); - // Issue #1213 (attempt 2, verdict MAJOR-1): a bus tap's binding. `frameSlot` is the strip - // index, so it resolves into `trackGrDb` below `trackCount` and into - // `submixGrDb[frameSlot - trackCount]` from there; for bus `0` that is the frame's - // `submixGrDb[0]`. Red if `observe()` reports a bus `frameSlot` the documented mapping does not - // land on the bus's entry, or if the mapping is read as a plain `trackGrDb` index (the - // pre-#1213 contract), where a bus slot falls past the end. + assert.equal(unsubscribed.bindings[0].stripId, "track-0"); + // Issue #1213 (attempt 2, verdict MAJOR-1), as #1677 leaves it: a bus tap is named by its + // submix's ID like a track's, and the binding carries no position of any plan. { - const frameReading = (frame, frameSlot) => frameSlot < frame.trackCount - ? frame.trackGrDb[frameSlot] - : frame.submixGrDb[frameSlot - frame.trackCount]; const busSubscription = { - trackIndex: fakeTrackCount, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 0, + stripId: "bus-0", rack: 1, effectId: "comp", tapId: 1, windowBlocks: 0, }; const busAck = await liveControlHost.observe({ subscriptions: [{ ...busSubscription, armed: true }], }); assert.equal(busAck.result, 0); - const busBinding = busAck.bindings.find( - (binding) => binding.trackIndex === fakeTrackCount, - ); - assert.equal(busBinding.frameSlot, fakeTrackCount, "a bus's frameSlot is its strip index"); - assert.equal(meterFrames[0].trackCount, fakeTrackCount); - assert.equal(frameReading(meterFrames[0], busBinding.frameSlot), meterFrames[0].submixGrDb[0], - "the documented mapping lands on the bus's submixGrDb entry"); - assert.equal(frameReading(meterFrames[0], busAck.bindings[0].frameSlot), - meterFrames[0].trackGrDb[0], "and a track's frameSlot still indexes trackGrDb"); + const busBinding = busAck.bindings.find((binding) => binding.stripId === "bus-0"); + assert.deepEqual(busBinding, { ...busSubscription, windowBlocks: Number(limits.liveControlMeterBlocks) }); + assert.deepEqual(Object.keys(busBinding).sort(), ["effectId", "rack", "stripId", "tapId", "windowBlocks"]); const busOff = await liveControlHost.observe({ subscriptions: [{ ...busSubscription, armed: false }], }); @@ -2263,12 +2260,13 @@ async function testMainRealm() { } for (const broken of [ // The retired `simd1`/`simd2` codes (`0`, `2`) and an unallocated one are refused (#1096). - { trackIndex: -1 }, { rack: 0 }, { rack: 2 }, { rack: 4 }, { tapId: 0 }, { armed: "yes" }, - { windowBlocks: -1 }, + { stripId: 0 }, { stripId: "x".repeat(128) }, { effectId: "x".repeat(128) }, { rack: 0 }, + { rack: 2 }, { rack: 4 }, { tapId: 0 }, { armed: "yes" }, { windowBlocks: -1 }, + { trackIndex: 0 }, { effectIndex: 0 }, ]) { await errorResult(liveControlHost.observe({ subscriptions: [{ - trackIndex: 0, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 0, armed: true, + stripId: "track-0", rack: 1, effectId: "comp", tapId: 1, windowBlocks: 0, armed: true, ...broken, }], }), 1); @@ -2297,12 +2295,15 @@ async function testMainRealm() { // The tap is declared and correctly addressed; this preparation bound no observation // capacity, which is what `RESULT_UNSUPPORTED` means. Retrying will never help. { reason: 11, result: 7, what: "a session that bound no observation capacity" }, + // Issue #1676: past the cap on armed taps. The value follows the retired 12-14, so a table + // bound that stopped at 12 or accepted a retired hole would read it as malformed. + { reason: 15, result: 7, what: "a subscription past the cap on armed taps" }, ]) { commandResult = result; commandMutation = (response) => ({ ...response, reason, rejectedIndex: 0, admitted: 0 }); const refused = await liveControlHost.observe({ subscriptions: [ - { trackIndex: 0, rack: 1, effectIndex: 0, tapId: 9, windowBlocks: 0, armed: true }, + { stripId: "track-0", rack: 1, effectId: "comp", tapId: 9, windowBlocks: 0, armed: true }, ], }); commandMutation = null; @@ -2351,15 +2352,15 @@ async function testMainRealm() { // And a *correct* subscription still arms, so nothing about the map machinery was poisoned. const recovered = await liveControlHost.observe({ subscriptions: [ - { trackIndex: 1, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 4, armed: true }, + { stripId: "track-1", rack: 1, effectId: "comp", tapId: 1, windowBlocks: 4, armed: true }, ], }); assert.equal(recovered.result, 0, `${what}: a correct subscription still arms after it`); assert.equal(recovered.reason, 0); - assert.deepEqual(recovered.bindings.map((binding) => binding.trackIndex), [0, 1]); + assert.deepEqual(recovered.bindings.map((binding) => binding.stripId), ["track-0", "track-1"]); const undo = await liveControlHost.observe({ subscriptions: [ - { trackIndex: 1, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 4, armed: false }, + { stripId: "track-1", rack: 1, effectId: "comp", tapId: 1, windowBlocks: 4, armed: false }, ], }); assert.deepEqual(undo.bindings, mapBeforeRefusal, "the map returns to where it started"); @@ -2448,41 +2449,41 @@ async function testMainRealm() { simd128ModuleUrl: "simd.wasm", workletModuleUrl: "processor.js", }); - const tap = (trackIndex, effectIndex, armed) => ({ - trackIndex, rack: 1, effectIndex, tapId: 1, windowBlocks: 0, armed, + const tap = (stripId, effectId, armed) => ({ + stripId, rack: 1, effectId, tapId: 1, windowBlocks: 0, armed, }); const beforeEdit = await prepare("{\"schema_version\":0}"); const armedBefore = await beforeEdit.observe({ - subscriptions: [tap(0, 0, true), tap(1, 0, true)], + subscriptions: [tap("track-0", "comp", true), tap("track-1", "comp", true)], }); assert.equal(armedBefore.result, 0); - assert.deepEqual(armedBefore.bindings.map((binding) => binding.trackIndex), [0, 1]); + assert.deepEqual(armedBefore.bindings.map((binding) => binding.stripId), ["track-0", "track-1"]); await beforeEdit.dispose(); const afterEdit = await prepare("format_version = 0 # one effect inserted"); // The replacement's map starts empty: request ids restart at 1 and nothing carried over. - // The app's first re-arm uses the *old* effect index, which the replacement no longer has. + // The app's first re-arm names the *old* effect, which the replacement no longer has. commandResult = 1; commandMutation = (response) => ({ ...response, reason: 10, rejectedIndex: 0, admitted: 0 }); const staleRearm = await afterEdit.observe({ - subscriptions: [tap(0, 0, true), tap(1, 0, true)], + subscriptions: [tap("track-0", "comp", true), tap("track-1", "comp", true)], }); commandMutation = null; commandResult = 0; assert.equal(staleRearm.tag, "miso.observe.v1"); - assert.equal(staleRearm.reason, 10, "a stale tap address is refused, per request"); + assert.equal(staleRearm.reason, 10, "a stale tap key is refused, per request"); assert.deepEqual(staleRearm.bindings, [], "the replacement plan's map is still empty"); - // The app reads the new addressing and re-arms. The host was never sticky, so this is a plain - // second call rather than a rebuild. + // The app names the replacement's effects and re-arms. The host was never sticky, so this is a + // plain second call rather than a rebuild. assert.equal((await afterEdit.sessionMap()).result, 0, "the session map still answers"); const rearmed = await afterEdit.observe({ - subscriptions: [tap(0, 1, true), tap(1, 1, true)], + subscriptions: [tap("track-0", "comp-2", true), tap("track-1", "comp-2", true)], }); assert.equal(rearmed.result, 0, "the replacement plan re-arms after the refusal"); - assert.deepEqual(rearmed.bindings.map((binding) => binding.effectIndex), [1, 1], - "the map holds the replacement's addressing, not the retired plan's"); + assert.deepEqual(rearmed.bindings.map((binding) => binding.effectId), ["comp-2", "comp-2"], + "the map holds the replacement's keys, not the retired plan's"); assert.deepEqual(rearmed.bindings.map((binding) => binding.windowBlocks), [Number(limits.liveControlMeterBlocks), Number(limits.liveControlMeterBlocks)], "`windowBlocks: 0` resolves against the replacement's own default"); @@ -2553,7 +2554,8 @@ async function testMainRealm() { // meter header's `submix_count` and the frame layout follow it, so a caller can run the worklet // with no submix (the production case before K2) or with several (their order is observable). function createFakeExports(quantum, backend = 1, liveControlsAttached = true, submixIds = ["drums-bus"]) { - const memory = { buffer: new ArrayBuffer(65536) }; + // Three pages: the 272-byte command records stage 69,632 bytes, beyond the first page. + const memory = { buffer: new ArrayBuffer(196608) }; const statusPointer = 16384; const resourcePointer = 17000; let status = new DataView(memory.buffer, statusPointer, 80); @@ -2593,7 +2595,7 @@ function createFakeExports(quantum, backend = 1, liveControlsAttached = true, su const controlRequestPointer = 50000; const controlResponsePointer = 56000; // Issue #137: command staging (kind 6), the meter frame (kind 7) and the command report. - const commandPointer = 24000; + const commandPointer = 70000; const meterFramePointer = 40000; const reportPointer = 41000; const meterHeaderPointer = 41100; @@ -2656,7 +2658,7 @@ function createFakeExports(quantum, backend = 1, liveControlsAttached = true, su }; const capacities = { 2: 64, 3: 2 * quantum * 4, 5: 2 * quantum * 4, - 6: liveControlsAttached ? 256 * 48 : 0, + 6: liveControlsAttached ? 256 * 272 : 0, 7: meterFrameFloats * 4, }; // Issue #143: the additive observation ABI is present even in this hermetic processor fake. @@ -2743,7 +2745,7 @@ function createFakeExports(quantum, backend = 1, liveControlsAttached = true, su }, miso_engine_web_v1_command_report_ptr: () => reportPointer, miso_engine_web_v1_command_submit: (_handle, count) => { - calls.commands.push(new Uint8Array(memory.buffer, commandPointer, count * 48).slice()); + calls.commands.push(new Uint8Array(memory.buffer, commandPointer, count * 272).slice()); report.setUint32(8, calls.commandResult, true); report.setUint32(12, calls.commandResult === 0 ? 0 : 8, true); report.setUint32(16, 0, true); @@ -3473,7 +3475,7 @@ async function testProcessor() { const processor = construct(fake); assert.deepEqual(processor.port.posts[0].message, { tag: "miso.ready.v1", requestId: 0, result: 0, backend: "simd128", - resources: processor.resources, memoryBytes: 65536, + resources: processor.resources, memoryBytes: 196608, }); return { processor, fake }; }; @@ -3662,7 +3664,7 @@ async function testProcessor() { assert(right.every((sample) => sample === -0.25)); processor.receive({ tag: "miso.status.v1", requestId: 1 }); const status = processor.port.posts.at(-1).message; - assert.equal(status.memoryBytes, 65536); + assert.equal(status.memoryBytes, 196608); assert.equal(status.nextAbsoluteSample, 64n); assert.equal( status.singleModeControlAllocations, @@ -3846,7 +3848,7 @@ async function testProcessor() { const status = processor.port.posts.map((post) => post.message) .filter((message) => message.tag === "miso.status.v1").at(-1); assert.equal(status.result, 0); - assert.equal(status.memoryBytes, 65536 * 4, "the status reports the grown memory"); + assert.equal(status.memoryBytes, 196608 * 4, "the status reports the grown memory"); assert.equal(status.renderedQuanta, 1n, "the status reads the grown memory"); assert.equal(status.staleViewQuanta, 0, "no quantum ran on a detached view"); assert.equal(processor.process([], [[left.fill(1), right.fill(1)]]), true); @@ -4041,7 +4043,7 @@ async function testProcessor() { const status = processor.port.posts.map((post) => post.message) .filter((message) => message.tag === "miso.status.v1").at(-1); assert.equal(status.staleViewQuanta, 2, "the status reports the silenced quanta"); - assert.equal(status.memoryBytes, 131072); + assert.equal(status.memoryBytes, 393216); left.fill(1); right.fill(1); assert.equal(processor.process([], [[left, right]]), true); @@ -4130,19 +4132,20 @@ async function testProcessor() { // Issue #137 D1: the worklet copies a staged batch into Wasm, admits it, and hands the // record block straight back with the typed report. const { processor, fake } = makeProcessor(); - const records = new Uint8Array(2 * 48); + const records = new Uint8Array(2 * 272); const view = new DataView(records.buffer); - records[0] = 2; // matrix - records[1] = 255; - records[2] = 255; + records[0] = 7; // subscribe + records[1] = 1; + records[2] = 5; + records[3] = 4; view.setUint32(4, 1, true); - view.setFloat32(24, 0.5, true); - records[48] = 1; // pan - records[49] = 255; - records[50] = 255; - view.setUint32(52, 0, true); - view.setFloat32(72, -1, true); - view.setFloat32(76, 1, true); + records.set(Buffer.from("track"), 16); + records.set(Buffer.from("comp0"), 16 + 128); + records[272] = 8; // unsubscribe + records[273] = 1; + records[274] = 5; + records[275] = 4; + view.setUint32(272 + 4, 1, true); processor.receive(structuredClone( { tag: "miso.command.v1", requestId: 1, count: 2, records }, { transfer: [records.buffer] }, @@ -4154,12 +4157,12 @@ async function testProcessor() { assert.equal(ack.message.reason, 0); assert.equal(ack.transferCount, 1, "the record block goes back to the caller"); assert.equal(fake.calls.commands.length, 1); - assert.equal(fake.calls.commands[0].length, 96, "exactly the staged bytes are submitted"); - assert.equal(fake.calls.commands[0][0], 2); - assert.equal(fake.calls.commands[0][48], 1); + assert.equal(fake.calls.commands[0].length, 544, "exactly the staged bytes are submitted"); + assert.equal(fake.calls.commands[0][0], 7); + assert.equal(fake.calls.commands[0][272], 8); // A record block whose length disagrees with `count` is sticky-invalid, never truncated. - const short = new Uint8Array(48); + const short = new Uint8Array(272); processor.receive({ tag: "miso.command.v1", requestId: 2, count: 2, records: short }); assert.equal(processor.port.posts.at(-1).message.tag, "miso.error.v1"); assert.equal(processor.port.posts.at(-1).message.result, 1); diff --git a/scripts/test-web-audioworklet.sh b/scripts/test-web-audioworklet.sh index 61aae249e..4efba82aa 100755 --- a/scripts/test-web-audioworklet.sh +++ b/scripts/test-web-audioworklet.sh @@ -241,7 +241,7 @@ cp "$repo_root/hosts/host-web/web/miso-engine-v1-audio-worklet-host.js" \ "$vocabulary_dir/hosts/host-web/web/" cp "$repo_root/tools/parameter-metadata/src/lib.rs" \ "$vocabulary_dir/tools/parameter-metadata/src/" -sed 's/^pub const COMMAND_REASON_OBSERVATION_UNBOUND: u32 = 11;/&\npub const COMMAND_REASON_FUTURE_TAP: u32 = 15;/' \ +sed 's/^pub const COMMAND_REASON_OBSERVATION_UNBOUND: u32 = 11;/&\npub const COMMAND_REASON_FUTURE_TAP: u32 = 16;/' \ "$repo_root/hosts/host-web/src/lib.rs" \ >"$vocabulary_dir/hosts/host-web/src/lib.rs" if diff -q "$repo_root/hosts/host-web/src/lib.rs" \ @@ -257,7 +257,7 @@ echo "web AudioWorklet command-reason vocabulary gates passed" # Issue #210 phase 0: the same discipline for the command-KIND vocabulary. Its own red mutations # run first -- 26 of them, one per rule -- then the shipped drift class is performed on disk: a -# kind the 48-byte wire decodes that the host JS `COMMAND_KINDS` set does not admit. That is the +# kind the command-record wire decodes that the host JS `COMMAND_KINDS` set does not admit. That is the # defect shape the metadata JSON shipped for two releases (six kinds declared, eight decoded). python3 -B "$repo_root/scripts/check-command-kind-vocabulary.py" --self-test python3 -B "$repo_root/scripts/check-command-kind-vocabulary.py" diff --git a/sdk/README.md b/sdk/README.md index 1dbb5ed5e..8eccd2fb2 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -270,8 +270,10 @@ batches through the queue, for gesture loops; the core's `backpressure` status i never an error. The root entry's `parameter()` helper provides exact decimal/lattice edits for agent-facing controls. -Observation subscriptions (`effect(...).observe(...)`) are the one edit that is still a 48-byte -command record: they resolve with a `CommandReport` and need a positive +Observation subscriptions (`console(...)` or `insert(...)` then `.observe(...)`) are the one edit that +is still a command record: it names its tap by stable key (the strip's ID, the console slot's or +insert's ID, the tap's ID), which the core resolves on the newest committed plan and refuses with a +typed reason when it has no such tap. They resolve with a `CommandReport` and need a positive `liveControls.observationTaps` at boot, passed to `createOfflineEngine` directly or under `policy` to `createEngine`. Value edits are `SET_CONTROL` transactions and need no boot word. @@ -315,12 +317,16 @@ writer. `address` is either a generated `ControlAddress` (owner, section, the ro lane) or the `address` of a list entry, which names the row by its numeric `control`. A row of an insert or console slot depends on the effect it runs, and the SDK reads that from the control list's section headers, not from the session it booted: an insert or console slot that is in the list can -be named by its machine name, and a rebuild that makes an insert run another effect is followed. The -effect row is checked again against the list when the transaction is sent (a probe of the structure -epoch, and the list only when it moved): a name follows the header, and a builder typed for an effect -that insert no longer runs (`insert("comp", "miso.compressor")` after `comp` became a soft clip) is -refused with `MisoUsageError` and sends nothing. A section the list does not have cannot be named; -use the list entry's numeric address. `set` checks only that the edit can be encoded. The value's domain, the lane, the owner and whether the +be named by its machine name. A named row takes its effect from the list when the edit is staged, +a typed effect builder takes the effect it is typed for, and the SDK sends that effect in field 7 +(`expected-effect`) of the `SET_CONTROL` address. If that insert or slot runs another effect when the core applies the edit +(`insert("comp", "miso.compressor")` after a rebuild made `comp` a soft clip), the core refuses +the transaction with `control.effect.changed`, which `submit()` rejects as `MisoControlRefusal`, and +nothing changes. A named row stays bound to the effect it was resolved for: after a rebuild, call +`engine.liveControls()` again so that names resolve against the new list. A numeric address names +no effect unless it carries `expectedEffect`, and without it the edit applies to whatever runs +there. A section the list does not have cannot be named; use the list entry's numeric address. +`set` checks only that the edit can be encoded. The value's domain, the lane, the owner and whether the row can be set are the core's: an edit of a derived row rejects `control.read_only`, and one of a rebuild-only row is committed on the rebuild path, as on the C ABI: the core prepares a replacement plan and the engine adopts it at a block boundary. diff --git a/sdk/assets/miso-engine-v1-abi-layout.json b/sdk/assets/miso-engine-v1-abi-layout.json index 0c685ce92..24cd35f74 100644 --- a/sdk/assets/miso-engine-v1-abi-layout.json +++ b/sdk/assets/miso-engine-v1-abi-layout.json @@ -435,20 +435,18 @@ } }, "commandRecord": { - "bytes": 48, + "bytes": 272, "endianness": "little", "fields": [ { "name": "kind", "offset": 0, "type": "u8" }, { "name": "rack", "offset": 1, "type": "u8" }, - { "name": "channel", "offset": 2, "type": "u8" }, - { "name": "reserved0", "offset": 3, "type": "u8" }, - { "name": "trackIndex", "offset": 4, "type": "u32" }, - { "name": "effectIndex", "offset": 8, "type": "u32" }, - { "name": "parameterId", "offset": 12, "type": "u32" }, - { "name": "smoothingSamples", "offset": 16, "type": "u32" }, - { "name": "reserved1", "offset": 20, "type": "u32" }, - { "name": "values", "offset": 24, "type": "f32[4]" }, - { "name": "reserved2", "offset": 40, "type": "u8[8]" } + { "name": "stripIdBytes", "offset": 2, "type": "u8" }, + { "name": "effectIdBytes", "offset": 3, "type": "u8" }, + { "name": "tapId", "offset": 4, "type": "u32" }, + { "name": "windowBlocks", "offset": 8, "type": "u32" }, + { "name": "reserved0", "offset": 12, "type": "u32" }, + { "name": "stripId", "offset": 16, "type": "u8[128]" }, + { "name": "effectId", "offset": 144, "type": "u8[128]" } ] }, "constants": { @@ -458,7 +456,7 @@ "backends": [{ "value": 0, "name": "scalar" }, { "value": 1, "name": "simd128" }], "bufferKinds": [{ "value": 2, "name": "sourceId" }, { "value": 3, "name": "sourcePcm" }, { "value": 4, "name": "diagnostic" }, { "value": 5, "name": "outputPcm" }, { "value": 6, "name": "command" }, { "value": 7, "name": "meterFrame" }], "wireCommandKinds": [{ "value": 7, "name": "observeSubscribe" }, { "value": 8, "name": "observeUnsubscribe" }], - "commandReasons": [{ "value": 0, "name": "none" }, { "value": 1, "name": "malformed" }, { "value": 2, "name": "unknownTrack" }, { "value": 3, "name": "unknownRack" }, { "value": 4, "name": "unknownEffect" }, { "value": 5, "name": "unknownParameter" }, { "value": 6, "name": "domain" }, { "value": 7, "name": "unsupportedKind" }, { "value": 8, "name": "backpressure" }, { "value": 9, "name": "wrongState" }, { "value": 10, "name": "unknownTap" }, { "value": 11, "name": "observationUnbound" }], + "commandReasons": [{ "value": 0, "name": "none" }, { "value": 1, "name": "malformed" }, { "value": 2, "name": "unknownTrack" }, { "value": 3, "name": "unknownRack" }, { "value": 4, "name": "unknownEffect" }, { "value": 5, "name": "unknownParameter" }, { "value": 6, "name": "domain" }, { "value": 7, "name": "unsupportedKind" }, { "value": 8, "name": "backpressure" }, { "value": 9, "name": "wrongState" }, { "value": 10, "name": "unknownTap" }, { "value": 11, "name": "observationUnbound" }, { "value": 15, "name": "observationLimit" }], "racks": [{ "value": 1, "name": "inserts" }, { "value": 3, "name": "console" }, { "value": 255, "name": "notApplicable" }], "liveResponseRacks": [{ "value": 0, "name": "inputFilters" }, { "value": 2, "name": "inserts" }, { "value": 4, "name": "console" }], "observationChannels": [{ "value": 1, "name": "left" }, { "value": 2, "name": "right" }, { "value": 3, "name": "both" }], diff --git a/sdk/assets/miso-engine-v1-parameter-metadata.json b/sdk/assets/miso-engine-v1-parameter-metadata.json index 233aa5c8d..6dd156158 100644 --- a/sdk/assets/miso-engine-v1-parameter-metadata.json +++ b/sdk/assets/miso-engine-v1-parameter-metadata.json @@ -1,7 +1,7 @@ { "schema": "miso.web.parameter-metadata.v1", "abiVersion": 65536, - "commandRecordBytes": 48, + "commandRecordBytes": 272, "maximumCommandRecords": 256, "commandKinds": [ { "value": 7, "name": "observeSubscribe", "applied": true, "plane": "observation" }, @@ -19,7 +19,8 @@ { "value": 8, "name": "backpressure" }, { "value": 9, "name": "wrongState" }, { "value": 10, "name": "unknownTap" }, - { "value": 11, "name": "observationUnbound" } + { "value": 11, "name": "observationUnbound" }, + { "value": 15, "name": "observationLimit" } ], "observationVocabularies": { "kinds": [{ "value": 1, "name": "gainReductionDb" }], diff --git a/sdk/codegen/generate.mjs b/sdk/codegen/generate.mjs index 099897360..80d7f0113 100644 --- a/sdk/codegen/generate.mjs +++ b/sdk/codegen/generate.mjs @@ -737,27 +737,24 @@ function renderControlBuilders(catalog) { + `export class TrackEdits extends StripEdits {\n` + ` constructor(\n` + ` trackId: string,\n` - + ` trackIndex: number | undefined,\n` + ` layout: LiveControlLayout | undefined,\n` + ` context: StripContext,\n` + ` ) {\n` - + ` super("track", trackId, trackIndex, layout, context);\n` + + ` super("track", trackId, layout, context);\n` + ` }\n\n` + strip(trackOnly) + `}\n\n` + `/**\n` + ` * A submix strip's live edits: every strip edit and no \`solo\` (a bus is solo-safe; the core refuses\n` - + ` * the solo of a submix). \`LiveControlEdits.submix(id)\` also finds the submix's strip index,\n` - + ` * \`T + j\`, for observation.\n` + + ` * the solo of a submix).\n` + ` */\n` + `export class SubmixEdits extends StripEdits {\n` + ` constructor(\n` + ` submixId: string,\n` - + ` stripIndex: number | undefined,\n` + ` layout: LiveControlLayout | undefined,\n` + ` context: StripContext,\n` + ` ) {\n` - + ` super("submix", submixId, stripIndex, layout, context);\n` + + ` super("submix", submixId, layout, context);\n` + ` }\n` + `}\n\n` + renderOwnedClass("RouteEdits", "route", diff --git a/sdk/src/browser/engine.ts b/sdk/src/browser/engine.ts index d2a1740a8..7ec7aefcd 100644 --- a/sdk/src/browser/engine.ts +++ b/sdk/src/browser/engine.ts @@ -696,11 +696,7 @@ export async function createEngine(options: CreateEngineOptions): Promise => (policy.liveControls?.observationTaps ?? 0) === 0 diff --git a/sdk/src/browser/live-controls.ts b/sdk/src/browser/live-controls.ts index 5f3d1d27b..4bdd40c9d 100644 --- a/sdk/src/browser/live-controls.ts +++ b/sdk/src/browser/live-controls.ts @@ -5,12 +5,11 @@ import { controlCallFailure, decodeControlLimits, encodeRevisionProbe } from ".. import { ControlRequestIds, ControlTransactionQueue } from "../core/control-queue.ts"; import { readControlStripOrder } from "../core/control-list.ts"; import type { ControlListTransport, ControlStripOrder } from "../core/control-list.ts"; -import { EngineLiveControls, effectRowResolver } from "../core/live-controls.ts"; +import { EngineLiveControls } from "../core/live-controls.ts"; import type { LiveControlBeforeSubmit } from "../core/live-controls.ts"; import { resultName } from "../core/errors.ts"; import type { SessionLike } from "../core/session.ts"; import type { LaneEdit } from "../core/writer.ts"; -import { laneEditWord } from "../internal/ramp.ts"; import type { MisoAudioWorkletHost, MisoCommand, @@ -27,12 +26,10 @@ function browserCommand(edit: LaneEdit): MisoCommand { return { kind: kindValue(edit.kind), rack: edit.rack, - channel: edit.channel, - trackIndex: edit.trackIndex, - effectIndex: edit.effectIndex ?? 0, - parameterId: edit.parameterId ?? 0, - smoothingSamples: laneEditWord(edit), - values: [...edit.values], + stripId: edit.stripId, + effectId: edit.effectId, + tapId: edit.tapId, + windowBlocks: edit.windowBlocks, }; } @@ -171,9 +168,6 @@ async function openBrowserControlQueue(host: MisoAudioWorkletHost): Promise (await probe()).revision, - // An edit that names an effect's row is checked against the core's list before it is sent - // (#1562b-K); the reader is the page's one, so a probe of the structure epoch is all it costs. - prepare: effectRowResolver(() => createBrowserStripOrders(host).current()), }); } @@ -205,7 +199,6 @@ export async function createBrowserLiveControls( }); return new EngineLiveControls(map, { queue, - stripOrder: orders.current, order, observe: async (edits): Promise => { const ack = await host.command({ commands: edits.map(browserCommand) }); diff --git a/sdk/src/browser/shipped-host.d.ts b/sdk/src/browser/shipped-host.d.ts index 9002425ec..f2fe46311 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -30,13 +30,15 @@ // `liveControlObservationTaps === 0n` is that session: it has no command staging, so `command()` // acknowledges every batch `UnsupportedKind`. // -// ## Addressing is session-stable and string-free +// ## Addressing is by stable key (issue 1677) // -// A command names a strip by its strip index -- the compiled session's canonical normalized tracks -// first, then its submixes in canonical order (issue 1213), as the core's control list names them -// (the owner header rows, track rows first) -- plus a rack, an effect index and a numeric tap ID. -// No string crosses the command path. The identity mapping is the control list for the session -// and the build-time metadata JSON for the effect vocabulary. +// A command names its tap by the stable key the engine's `OBSERVATION_ENDED` event uses: the +// strip's ID (a track or a submix), the rack (`1` inserts, `3` console), the insert's or the +// console slot's ID, and the effect-local tap ID. It carries no position, so a rebuild cannot make +// a record arm another effect's tap. The engine resolves the key on the newest committed plan -- +// the successor between a rebuild's commit and the block that adopts it -- and refuses a key that +// plan does not have with a typed reason (`UnknownTrack`, `UnknownEffect`, `UnknownTap`) before it +// queues anything. The build-time metadata JSON is the vocabulary of the tap IDs. // // ## One batch is one transaction // @@ -189,10 +191,9 @@ export interface MisoUnsupportedBrowser { export const enum MisoCommandKind { /// Arm one declared observation tap of one effect instance. Applied (issue #143). /// - /// `parameterId` carries the effect-local **tap id** from the metadata JSON's per-effect - /// `observations` array, `smoothingSamples` carries the window length in render blocks (`0` for - /// the plan's default), and every `values` word must be `0`: a subscription changes what is read, - /// never what is rendered. + /// `tapId` carries the effect-local **tap id** from the metadata JSON's per-effect + /// `observations` array and `windowBlocks` the window length in render blocks (`0` for the + /// plan's default): a subscription changes what is read, never what is rendered. /// /// Applied on the `miso.observe.v1` plane: it binds an entry in the subscription map. The /// metadata JSON reports it as `"plane": "observation"`. @@ -208,18 +209,19 @@ export const enum MisoCommandKind { export const enum MisoCommandReason { /// The submission was admitted whole. None = 0, - /// Unknown kind, nonzero reserved word, non-finite value, a field set for the wrong kind, or a - /// submission that names one observation tap more than once. + /// Unknown kind, nonzero reserved word, an ID field that is not its length's bytes followed by + /// zeros, or a submission that names one observation tap more than once. Malformed = 1, - /// `trackIndex` is not a strip of the compiled session: it is at or past the control list's - /// track and submix owner rows together. The spelling is kept (issue 1213, DESIGN P17). + /// `stripId` is not a track or a submix of the newest committed plan. The spelling is kept (issue + /// 1213, DESIGN P17). UnknownTrack = 2, /// `rack` is not an effect rack (`1` inserts or `3` console) on an effect-addressed kind. The /// retired `simd1` (`0`) and `simd2` (`2`) codes are refused with it only on the raw export /// path (issue #1096); `MisoAudioWorkletHost.command()` refuses them locally, before the port, /// by rejecting with a `miso.error.v1` invalid argument, as it refuses any out-of-set code. UnknownRack = 3, - /// `effectIndex` is not an effect of the addressed rack: no such console slot or insert. + /// `effectId` is not a console slot or insert (per `rack`) of the addressed strip on the newest + /// committed plan. UnknownEffect = 4, /// `parameterId` is not a parameter of the addressed effect. UnknownParameter = 5, @@ -232,10 +234,12 @@ export const enum MisoCommandReason { /// drained; nothing was admitted, and a render clears it. The reason is also returned with the internal result /// code, not a backpressure one, when the plan's revision gate refuses the batch's stamp (a /// revision past the gate's ceiling); that batch wrote nothing. + /// + /// Never the answer to the cap on armed taps, which is `ObservationLimit`. Backpressure = 8, /// The engine is not `STATE_READY`. WrongState = 9, - /// `parameterId` is not a declared observation tap of the addressed effect (issue 143). + /// `tapId` is not a declared observation tap of the addressed effect (issue 143). /// /// Deliberately not `UnknownParameter`: a parameter and a tap are different namespaces on the /// same effect, and a caller that confuses them learns which one it got wrong. @@ -247,33 +251,36 @@ export const enum MisoCommandReason { ObservationUnbound = 11, // Reasons 12-14 (`NotSoloable`, `UnknownRoute`, `UnknownVca`) were deleted with the value kinds // (issue #1565); their numbers are never reused. + /// The subscription would leave more taps armed than the engine can end honestly in one + /// `OBSERVATION_ENDED` event in one control frame (issue 1676: 51 taps at the browser's 16 KiB + /// frame). `rejectedIndex` names the subscription that crossed the cap, after every unsubscribe + /// of the same submission is counted; nothing was admitted. The result is `RESULT_UNSUPPORTED`. + /// + /// A render does not make room, so retrying the same submission will not help, which is why this + /// is its own reason and not `Backpressure`: unsubscribe another tap first. + ObservationLimit = 15, } -/// One live-control command record. `255` means "not applicable to this kind". +/// One live-control command record: the subscription or withdrawal of one tap, named by its stable +/// key (issue 1677). /// /// Only the two observation kinds are carried (issue #1565). An effect is addressed in the -/// session's terms (decision 12, issue #1096): a console slot as `rack` `3` with `effectIndex` the -/// slot's index in the session's slot order (`pre_insert`, then `post_insert`, which is its index in -/// the track's `console` entries), and an insert as `rack` `1` with `effectIndex` its index in the -/// track's `inserts`. The retired `simd1` (`0`) and `simd2` (`2`) codes are refused and never -/// reallocated; the layout JSON's `racks` table is the vocabulary. +/// session's terms (decision 12, issue #1096): a console slot as `rack` `3` with `effectId` the +/// slot's ID, and an insert as `rack` `1` with `effectId` the insert's ID. The retired `simd1` (`0`) +/// and `simd2` (`2`) codes are refused and never reallocated; the layout JSON's `racks` table is the +/// vocabulary. An ID is at most 127 UTF-8 bytes; a longer one rejects locally. export interface MisoCommand { kind: MisoCommandKind; /// `1` inserts, `3` console. rack: number; - /// `0` left, `1` right, `2` both, `255` for a kind with no lane (both observation kinds). - channel: number; - /// Strip index: the control list's track owner rows first, then its submix owner rows, each in - /// list order (issue 1213). - trackIndex: number; - /// A console slot's index in the session's slot order, or an insert's index. - effectIndex: number; + /// The strip's ID: a track's or a submix's. + stripId: string; + /// The console slot's ID or the insert's ID. + effectId: string; /// The effect-local observation tap id. - parameterId: number; + tapId: number; /// The observation window length in render blocks, or `0` for the plan's default. - smoothingSamples: number; - /// Every word `0` for the observation kinds. - values: [number, number, number, number]; + windowBlocks: number; } export interface MisoCommandRequest { @@ -584,13 +591,13 @@ export interface MisoMeterFrame { /// unknown field with `RESULT_INVALID_ARGUMENT` (1) before anything reaches the port, so an /// optional-looking extra property is a local refusal rather than a field the engine ignores. export interface MisoObservationSubscription { - /// Strip index, as `MisoCommand.trackIndex`: tracks first, then submixes (issue 1213), so a - /// bus effect's tap is armed at `tracks.length + j`. - trackIndex: number; + /// The strip's ID, as `MisoCommand.stripId`: a track's or a submix's. + stripId: string; /// `1` inserts or `3` console, as `MisoCommand` addresses an effect. There is no `255` here: /// a tap always names a rack. rack: number; - effectIndex: number; + /// The console slot's ID or the insert's ID, as `MisoCommand.effectId`. + effectId: string; /// The effect-local tap id from the metadata JSON's per-effect `observations[].id`. Never `0`. /// /// This is a *tap* id, not a parameter id: they are different namespaces on one effect, and a @@ -617,16 +624,15 @@ export interface MisoObservationRequest { /// This is where "which tracks have an observed effect at all" lives. `trackGrDb` is positional /// and cannot express absence; this can. export interface MisoObservationBinding { - readonly trackIndex: number; + /// The strip's ID. The strip's gain-reduction entry in a meter frame is at its position in the + /// control list's strip order (tracks, then submixes). + readonly stripId: string; /// `1` inserts or `3` console. readonly rack: number; - readonly effectIndex: number; + /// The console slot's ID or the insert's ID. + readonly effectId: string; /// The effect-local tap id, matching the metadata JSON's per-effect `observations[].id`. readonly tapId: number; - /// The strip index of the gain-reduction entry this tap folds into, equal to `trackIndex` - /// (issue 1213). Below `MisoMeterFrame.trackCount` it indexes `trackGrDb`; from there it is - /// `submixGrDb[frameSlot - trackCount]`. - readonly frameSlot: number; /// Render blocks per published window, as it is actually in force. readonly windowBlocks: number; } @@ -645,18 +651,18 @@ export interface MisoObservationBinding { /// further `observe()` calls all keep working. Only a locally malformed request rejects, with /// `MisoError` and `result: 1`, before anything is sent. /// -/// # Subscriptions are per plan +/// # Subscriptions follow the key /// -/// A structural session edit replaces the plan, and subscriptions belong to the plan they were -/// applied to: the replacement's lanes exist and are unarmed, and the readers the old plan handed -/// out simply stop advancing. An app re-subscribes against the new plan and receives a fresh map -/// whose sequences restart at `1`. Nothing carries over, and nothing has to be torn down. +/// A tap is named by its stable key, so a structural session edit does not move it: the core keeps +/// the tap armed on the replacement plan while the strip, the effect and the tap still exist, and +/// announces a tap it ended with an `OBSERVATION_ENDED` event. `bindings` is this host's own record +/// of what it armed and withdrew; it does not drop a tap the engine ended. export interface MisoObservationAck { readonly tag: "miso.observe.v1"; readonly requestId: number; readonly result: number; readonly reason: MisoCommandReason; - /// Every armed tap of the current plan, in canonical `(track, rack, effectIndex, tapId)` order. + /// Every tap this host armed and has not withdrawn, in `(stripId, rack, effectId, tapId)` order. readonly bindings: MisoObservationBinding[]; } diff --git a/sdk/src/core/boundary.ts b/sdk/src/core/boundary.ts index b3b5fe18e..842020caf 100644 --- a/sdk/src/core/boundary.ts +++ b/sdk/src/core/boundary.ts @@ -742,7 +742,7 @@ export class WasmBoundary { tapIds, }); } - return enrichObservationMap(strips, raw, order.structureEpoch); + return enrichObservationMap(strips, raw); } /** Internal managed-subscription view of the optional prepared spectrum query. */ @@ -1457,7 +1457,7 @@ export class WasmBoundary { } /** - * Stage `count` already-encoded 48-byte command records and submit them as one transaction. + * Stage `count` already-encoded command records (`ABI_LAYOUT.commandRecord.bytes` each) and submit them as one transaction. * * Only the observation records (kinds 7 and 8) are command records any more; a value edit is a * `SET_CONTROL` transaction (`controlFrame`). diff --git a/sdk/src/core/control-builder-support.ts b/sdk/src/core/control-builder-support.ts index 3e099d4fb..e2668668c 100644 --- a/sdk/src/core/control-builder-support.ts +++ b/sdk/src/core/control-builder-support.ts @@ -324,11 +324,8 @@ export const NO_LAYOUT = "this engine was not booted from a session the SDK buil /** What an effect instance is made from: the strip, the rack and the two ways to name it. */ export interface EffectInstance { readonly stripId: string; - readonly stripIndex: number | undefined; readonly rack: LiveControlRack; - /** Its live index, which observation records name it by. */ - readonly effectIndex: number | undefined; - /** Its console slot ID or insert ID, which a control address names it by. */ + /** Its console slot ID or insert ID, which a control address and an observation record name it by. */ readonly stableId: string | undefined; readonly effectId: E; } @@ -345,8 +342,6 @@ export interface StripContext { * The generated `StripEdits` extends this with one builder per row of the strip's tables. */ export abstract class StripAddressing { - /** The strip's index in the session map (the tracks, then the submixes), for observation. */ - protected readonly stripIndex: number | undefined; protected readonly stripId: string; /** `track 'kick'` or `submix 'drums'`, for messages. */ readonly #label: string; @@ -357,12 +352,10 @@ export abstract class StripAddressing { protected constructor( kind: StripKind, stripId: string, - stripIndex: number | undefined, layout: LiveControlLayout | undefined, context: StripContext, ) { this.stripId = stripId; - this.stripIndex = stripIndex; this.#label = `${kind} '${stripId}'`; this.#layout = layout; this.#instances = (kind === "track" ? layout?.tracks : layout?.submixes)?.get(stripId); @@ -381,10 +374,9 @@ export abstract class StripAddressing { /** * Address an effect by its live index: a console slot by its index in the session's slot order - * (`pre_insert`, then `post_insert`), or an insert by its index in the strip's chain. Observation - * records address an effect this way. Value edits address it by its stable ID, so the index must - * resolve against the session the SDK built (`withSession()`); use `console()` or `insert()` - * with an ID otherwise. + * (`pre_insert`, then `post_insert`), or an insert by its index in the strip's chain. Value edits + * and observation records address an effect by its stable ID, so the index must resolve against + * the session the SDK built (`withSession()`); use `console()` or `insert()` with an ID otherwise. */ effect( rack: LiveControlRack, @@ -399,9 +391,7 @@ export abstract class StripAddressing { const index = requireU32(effectIndex, "effectIndex"); return this.#context.effect({ stripId: this.stripId, - stripIndex: this.stripIndex, rack, - effectIndex: index, stableId: this.#instances?.[rack][index]?.id, effectId, }); @@ -416,9 +406,7 @@ export abstract class StripAddressing { if (this.#layout === undefined) { return this.#context.effect({ stripId: this.stripId, - stripIndex: this.stripIndex, rack: "console", - effectIndex: undefined, stableId: slot, effectId, }); @@ -448,9 +436,7 @@ export abstract class StripAddressing { if (this.#layout === undefined) { return this.#context.effect({ stripId: this.stripId, - stripIndex: this.stripIndex, rack: "inserts", - effectIndex: undefined, stableId: insert, effectId, }); diff --git a/sdk/src/core/control-frame.ts b/sdk/src/core/control-frame.ts index 920640607..1bf816808 100644 --- a/sdk/src/core/control-frame.ts +++ b/sdk/src/core/control-frame.ts @@ -18,7 +18,7 @@ import { MisoEngineError, MisoUsageError, parseDiagnostics, resultName } from ". * `id:u16, wire:u8, flags:u8, length:u32`, the value, and zero padding to 8 bytes. A nested message * starts with `count:u32, reserved:u32`. The field numbers below are the protocol schema's * (`crates/protocol/src/schema.rs`: `session::transaction`, `session::edit`, `session::set_control`, - * `control_address`); `crates/protocol/tests/fixtures/set-control-frames.json` holds the Rust codec's + * `set_control_address`); `crates/protocol/tests/fixtures/set-control-frames.json` holds the Rust codec's * bytes for a fixed list of edits and `sdk/test/control-frame-evals.mjs` compares this encoder to it. */ @@ -72,6 +72,14 @@ export interface ControlWireAddress { readonly control: number; /** `left`, `right`, or `both` lanes at once. */ readonly lane: ControlLane; + /** + * The native effect (its catalog ID, `miso.compressor`) the edit was written for, in a console or + * insert section only (#1678). The numeric `control` of such a row depends on the effect, and a + * rebuild can replace the effect an insert or slot runs: the core refuses the edit with + * `control.effect.changed` when what runs there now is another effect, and applies it to whatever + * runs there when this is absent. + */ + readonly expectedEffect?: string; } /** One `SET_CONTROL` edit: a value for one lane of one control, with an optional ramp length. */ @@ -227,6 +235,12 @@ function addressMessage(address: ControlWireAddress): Uint8Array { } fields.push(field(5, WIRE_U32, false, u32(requireU32(address.control, "a control row ID")))); fields.push(field(6, WIRE_U8, false, u8(lookup(LANE_CODES, address.lane, "a lane")))); + if (address.expectedEffect !== undefined) { + if (section.kind !== "console" && section.kind !== "inserts") { + throw new MisoUsageError("an expected effect names the effect of a console slot or an insert"); + } + fields.push(field(7, WIRE_UTF8, false, text(requireId(address.expectedEffect, "an expected effect ID")))); + } return messageBody(fields); } @@ -275,9 +289,14 @@ export function controlEditBytes(edit: ControlFrameEdit): number { return editField(edit).length; } -/** The address an edit writes: two edits with one key write the same lane of the same control. */ +/** + * The address an edit writes: two edits with one key write the same lane of the same control, for + * the same expected effect. The expected effect is part of the key: an edit written for one effect + * and an edit written for another (or for none) are different requests, which the core may answer + * differently, so neither may supersede the other. + */ export function controlEditKey(edit: ControlFrameEdit): string { - const { owner, section, control, lane } = edit.address; + const { owner, section, control, lane, expectedEffect } = edit.address; return JSON.stringify([ owner.kind, "id" in owner ? owner.id : "", @@ -285,6 +304,7 @@ export function controlEditKey(edit: ControlFrameEdit): string { section.kind === "console" ? section.slot : section.kind === "inserts" ? section.effect : "", control, lane, + expectedEffect ?? null, ]); } diff --git a/sdk/src/core/control-queue.ts b/sdk/src/core/control-queue.ts index 188186cf6..791bff988 100644 --- a/sdk/src/core/control-queue.ts +++ b/sdk/src/core/control-queue.ts @@ -63,16 +63,6 @@ export interface ControlQueueOptions { * reply, because the core may have committed the transaction whose reply was lost. */ readonly readRevision?: (() => bigint | PromiseLike) | undefined; - /** - * Called with the edits of each transaction, coalesced, just before the frame is encoded. It - * returns the edits to send: the same count, in the same order, each standing for the edit at its - * position (an edit that names an effect's row by name is resolved against the core's current - * control list here, #1562b-K). A rejection fails the submits of that transaction as an encoding - * fault does: when it carried several, each is sent alone and only the one at fault is rejected. - */ - readonly prepare?: (( - edits: readonly ControlFrameEdit[], - ) => readonly ControlFrameEdit[] | PromiseLike) | undefined; } /** What the queue counted since it was made. */ @@ -236,7 +226,6 @@ export class ControlTransactionQueue { readonly #ids: ControlRequestIds; readonly #limits: ControlLimits | undefined; readonly #readRevision: (() => bigint | PromiseLike) | undefined; - readonly #prepare: ControlQueueOptions["prepare"]; #revision: bigint; /** The batch being staged, which the next transaction sends. */ #next: Batch | undefined; @@ -262,7 +251,6 @@ export class ControlTransactionQueue { this.#revision = revision; this.#limits = options.limits; this.#readRevision = options.readRevision; - this.#prepare = options.prepare; } /** @@ -497,25 +485,12 @@ export class ControlTransactionQueue { const { edits, owners } = coalesce(group); let requestId = 0n; let reply: ControlReply; - // The edits as sent: `edits` unless `prepare` stood other edits in for some of them. - let sent: readonly ControlFrameEdit[] = edits; try { - if (this.#prepare !== undefined) { - sent = await this.#prepare(edits); - if (sent.length !== edits.length) { - throw new MisoEngineError("the queue's prepare hook changed the number of edits", { - phase: "lifecycle", - code: "internal", - }); - } - } - // The ID is drawn after `prepare`, which reads the core's list with IDs of its own: the - // core takes IDs in ascending order. requestId = this.#ids.next(); const frame = encodeControlTransaction({ requestId, expectedRevision: this.#revision, - edits: sent, + edits, }); this.#transactions += 1; reply = decodeControlReply(await this.#send(frame)); @@ -560,11 +535,10 @@ export class ControlTransactionQueue { // The named edit, in its own submit's coordinates; `undefined` when it names none of them. let own: number | undefined; if (culprit !== undefined) { - // The core names the edit as it was sent; the submit holds the edit as it was staged. - const key = controlEditKey(sent[index!]!); + // The core names the edit as it was sent; the submit finds it among the edits it staged. + const key = controlEditKey(edits[index!]!); culprit.edits.forEach((edit, at) => { - const stood = edits.indexOf(edit); - if (controlEditKey(stood < 0 ? edit : sent[stood]!) === key) own = at; + if (controlEditKey(edit) === key) own = at; }); } if (culprit === undefined || !sameSession) { diff --git a/sdk/src/core/live-controls.ts b/sdk/src/core/live-controls.ts index 57ff450e6..db82601e1 100644 --- a/sdk/src/core/live-controls.ts +++ b/sdk/src/core/live-controls.ts @@ -64,124 +64,16 @@ export { EffectControlBuilders, RouteEdits, SessionEdits, StripEdits, SubmixEdit from "../generated/control-builders.ts"; export type { InputFilterValues, MatrixValues } from "../generated/control-builders.ts"; -/** - * A managed observation record cannot be addressed yet: the plan that renders is older than the - * newest committed one, so the effect indices it reports belong to another plan than the one the - * core admits the record against. It is `backpressure`, the typed retryable refusal: a render adopts - * the newer plan, and the caller sends the request again (a browser engine waits for that render - * itself, within its request deadline). - */ -export function structureBehindError(detail: string): MisoEngineError { - return new MisoEngineError( - `the plan that renders is older than the newest committed plan; render and try again: ${detail}`, - { - phase: "lifecycle", - code: "backpressure", - result: constantValue("resultCodes", "backpressure"), - diagnostics: [{ code: "sdk.observation.structure_behind", path: "" }], - }, - ); -} - /** What live controls send through: the engine's one transaction queue, and the observation records. */ export interface LiveControlTransport { /** Every value edit goes here, as a `SET_CONTROL` transaction. */ readonly queue: ControlTransactionQueue; - /** Observation records (kinds 7 and 8) stay 48-byte commands until the browser-feature phase. */ + /** Observation records (kinds 7 and 8): command records that name their tap by stable key (#1677). */ readonly observe: (edits: readonly LaneEdit[]) => CommandReport | Promise; - /** - * The strips and effects of the core's control list as they are now (#1562b-K): a probe of the - * structure epoch, and a read of the list only when it moved. Absent for a scripted transport - * that has no list; live controls then keep the order they were made with. - */ - readonly stripOrder?: StripOrderSource; /** The order the transport read when it made these live controls. */ readonly order?: ControlStripOrder; } -/** Reads the core's strip order and effects; the caller caches by structure epoch. */ -export type StripOrderSource = () => ControlStripOrder | Promise; - -/** What a built edit remembers of the effect row it names, to name it again after a rebuild. */ -interface RowProvenance { - readonly stripId: string; - readonly section: - | { readonly kind: "console"; readonly slot: string } - | { readonly kind: "inserts"; readonly effect: string }; - /** The row's machine name in its effect's control table. */ - readonly name: string; - /** The effect the numeric `control` was resolved for. */ - readonly effectId: string; - /** - * `true` when the caller typed the effect (`insert("c", "miso.compressor")`): the edit is refused - * if the core's section header now names another. `false` for an address named by the section - * alone, which follows the header. - */ - readonly strict: boolean; -} - -const PROVENANCE = new WeakMap(); -/** The strip an observation edit names by index, to name it again after a rebuild. */ -const OBSERVED_STRIP = new WeakMap(); - -function noteRow(set: ControlEditSet, row: RowProvenance): ControlEditSet { - for (const edit of set.edits) PROVENANCE.set(edit, row); - return set; -} - -/** - * The hook that resolves the effect rows of a transaction against the core's control list - * (#1562b-K). A rebuild can change the effect an insert or console slot runs, and the numeric row - * of an effect's control depends on the effect, so an edit built before the rebuild could land on - * another effect's row with the same number. Before each transaction is encoded, an edit that names - * an effect row is checked against the section header the core lists now (a probe of the structure - * epoch, and the list again only when it moved): an address named by its section follows the header, - * and one built for a typed effect that is no longer there is refused. Edits that name no effect row - * cost nothing. - */ -export function effectRowResolver( - source: StripOrderSource, -): (edits: readonly ControlFrameEdit[]) => readonly ControlFrameEdit[] | Promise { - return (edits) => { - if (!edits.some((edit) => PROVENANCE.has(edit))) return edits; - return (async () => { - const order = await source(); - return edits.map((edit) => { - const row = PROVENANCE.get(edit); - if (row === undefined) return edit; - const actual = stripEffectOf(order, row.stripId, row.section); - // No such section in the list: the core refuses the address with its own code. - if (actual === undefined || actual === row.effectId) return edit; - const where = `${row.section.kind === "console" ? "console slot" : "insert"} ` - + `'${row.section.kind === "console" ? row.section.slot : row.section.effect}' of strip '${row.stripId}'`; - if (row.strict) { - throw new MisoUsageError( - `${where} runs ${actual} now, and this edit was built for ${row.effectId}`, - ); - } - const control = effectControlIdIfRow(actual, row.name); - if (control === undefined) { - throw new MisoUsageError(`${where} runs ${actual} now, which has no control '${row.name}'`); - } - const resolved = Object.freeze({ - ...edit, - address: Object.freeze({ ...edit.address, control }), - }); - PROVENANCE.set(resolved, { ...row, effectId: actual }); - return resolved; - }); - })(); - }; -} - -function effectControlIdIfRow(effectId: string, name: string): number | undefined { - try { - return effectControlId(effectId, name); - } catch { - return undefined; - } -} - /** * Optional owner hook used to serialize observation edits with managed subscriptions. It sees the * observation edits as the builders made them. @@ -205,7 +97,6 @@ const RACKS: Readonly> = Object.freeze({ console: rackCode("console"), inserts: rackCode("inserts"), }); -const NONE = rackCode("notApplicable"); function modelOf(session: SessionLike): SessionModel { const candidate = session as { readonly toJSON?: unknown; readonly schema_version?: unknown }; @@ -321,38 +212,48 @@ function controlEdit( value: number, lane: ControlLane, rampSamples: number | undefined, + expectedEffect: string | undefined, ): ControlFrameEdit { return Object.freeze({ - address: Object.freeze({ owner, section, control, lane }), + address: Object.freeze({ + owner, + section, + control, + lane, + // The effect the edit was written for; the core refuses it if the slot runs another (#1678). + ...(expectedEffect === undefined ? {} : { expectedEffect }), + }), value, // Absent when none was given: the core resolves the row's ramp. ...(rampSamples === undefined ? {} : { rampSamples }), }); } -function observationEdit( - kind: LaneEdit["kind"], - stripId: string, - stripIndex: number, - options: { - readonly rack: number; - readonly effectIndex: number; - readonly parameterId: number; - readonly smoothingSamples: number; +/** + * One observation record: the subscription or withdrawal of the tap `tapId` of the console slot or + * insert `effectId` (its ID in the session) of strip `stripId`. The record names its tap by this + * stable key and no position, so the core resolves it on the plan it admits the record against and + * a rebuild cannot make it arm another effect's tap (#1677). Whether the key names a tap is the + * core's to say, with a typed reason. + */ +export function observationEdit( + armed: boolean, + key: { + readonly stripId: string; + readonly rack: LiveControlRack; + readonly effectId: string; + readonly tapId: number; }, + windowBlocks: number, ): LaneEdit { - const edit: LaneEdit = Object.freeze({ - kind, - trackIndex: stripIndex, - rack: options.rack, - channel: NONE, - effectIndex: options.effectIndex, - parameterId: options.parameterId, - smoothingSamples: options.smoothingSamples, - values: Object.freeze([0, 0, 0, 0]) as unknown as readonly [number, number, number, number], + return Object.freeze({ + kind: armed ? "observeSubscribe" : "observeUnsubscribe", + rack: RACKS[key.rack], + stripId: id(key.stripId, "a strip ID"), + effectId: id(key.effectId, "an effect ID"), + tapId: requireU32(key.tapId, "tapId"), + windowBlocks: requireU32(windowBlocks, "windowBlocks"), }); - OBSERVED_STRIP.set(edit, stripId); - return edit; } /** @@ -361,26 +262,25 @@ function observationEdit( * Builders address by stable ID and build `SET_CONTROL` edits; they check no value, no lane's * meaning and no index, and send nothing. An ID the engine does not have, a value outside a row's * domain and a lane a row does not allow come back from the core as its own `control.*` code, the - * same on the browser and on the headless engine. The session map supplies only the strip order, - * for observation records, which address a strip by index. + * same on the browser and on the headless engine. An observation record names its tap by the + * strip's ID, the effect's ID in the session and the tap's ID, and the core resolves them (#1677). * * Given the session the SDK built for this engine, it also resolves console slot IDs and insert IDs * on every strip (`console()`, `insert()`) against the booted session, and an effect addressed by * index (`effect()`) to its stable ID. */ export class LiveControlEdits { - #tracks: ReadonlyMap; - #submixes: ReadonlyMap; - #layout: LiveControlLayout | undefined; - /** The core's strips and effects as last read; supplies the effect of a named section. */ - #order: ControlStripOrder | undefined; + readonly #tracks: ReadonlySet; + readonly #submixes: ReadonlySet; + readonly #layout: LiveControlLayout | undefined; + /** The core's strips and effects as the live controls were made; supplies the effect of a named section. */ + readonly #order: ControlStripOrder | undefined; /** What a strip reaches `set` and its effect builders through. */ readonly #context: StripContext; constructor(map: SessionMap, session?: SessionLike, order?: ControlStripOrder) { - this.#tracks = new Map(map.tracks.map((trackId, index) => [trackId, index] as const)); - const trackCount = map.tracks.length; - this.#submixes = new Map(map.submixes.map((submixId, index) => [submixId, trackCount + index] as const)); + this.#tracks = new Set(map.tracks); + this.#submixes = new Set(map.submixes); this.#layout = session === undefined ? undefined : layoutOf(session, map); this.#order = order; this.#context = { @@ -392,26 +292,6 @@ export class LiveControlEdits { }; } - /** - * Follow the core's control list (#1562b-K): the strip indices that observation records name, and - * the effect each named section runs, are the list's after a rebuild. Builders made before this - * call keep what they were made with. A session the SDK built that no longer describes the strips - * is dropped from the layout, since the engine is no longer that session. - */ - update(order: ControlStripOrder): void { - if (order === this.#order) return; - this.#order = order; - this.#tracks = new Map(order.tracks.map((trackId, index) => [trackId, index] as const)); - this.#submixes = new Map( - order.submixes.map((submixId, index) => [submixId, order.tracks.length + index] as const), - ); - const layout = this.#layout; - if (layout !== undefined - && !(sameIds(layout.tracks.keys(), order.tracks) && sameIds(layout.submixes.keys(), order.submixes))) { - this.#layout = undefined; - } - } - /** * Set any control of the running session: one edit for one lane of one row, the sugar every typed * builder (`faderDb`, `gainDb`, an effect's `parameter`, ...) is built on. Returns the edit to pass @@ -423,6 +303,11 @@ export class LiveControlEdits { * is named by its machine name only when the effect it runs is known, which it is for a session * the SDK built (`withSession()`); otherwise name it by its numeric ID. * + * An edit of a console slot or insert row carries the effect it was written for (the typed effect of + * an effect builder, the effect of the section a row is named by, or `expectedEffect` of a numeric + * address), and the core refuses it with `control.effect.changed` when the slot or insert runs + * another effect now (#1678); an address that names no effect applies to whatever runs there. + * * It checks only that the edit can be encoded (a name that is a row, an ID that is a string, a * ramp that is a `u32`). The value's domain, the lane, the owner and whether the row can be set * are the core's to say, and its refusal (`control.domain`, `control.read_only`, @@ -440,33 +325,25 @@ export class LiveControlEdits { ? undefined : requireU32(options.rampSamples, "rampSamples"); const named = typeof address.control !== "number"; + const { owner, section } = address; + // A row of a console slot or an insert is sent with the effect it was resolved for: the typed + // effect of an effect builder, the effect of the named section, or the one a numeric address + // names itself. The core refuses the edit when the section runs another effect now (#1678). + const expectedEffect = named && owner.kind === "strip" + && (section.kind === "console" || section.kind === "inserts") + ? this.#effectOf(owner, section) + : (address as ControlWireAddress).expectedEffect; const edit = controlEdit( - address.owner, - address.section, + owner, + section, named ? this.#rowId(address as ControlAddress) : address.control as number, value, address.lane, rampSamples, + expectedEffect, ); validateControlEdit(edit); - const set = editSet(edit); - const { owner, section } = address; - if (named && owner.kind === "strip" && (section.kind === "console" || section.kind === "inserts")) { - // A row named by its section follows the core's header for that section at send time. - const effectId = this.#effectOf(owner, section); - if (effectId !== undefined) { - noteRow(set, { - stripId: owner.id, - section: section.kind === "console" - ? { kind: "console", slot: section.slot } - : { kind: "inserts", effect: section.effect }, - name: address.control as string, - effectId, - strict: false, - }); - } - } - return set; + return editSet(edit); } /** The numeric ID of the row a named address names. */ @@ -544,7 +421,6 @@ export class LiveControlEdits { track(trackId: string): TrackEdits { return new TrackEdits( id(trackId, "a track ID"), - this.#tracks.get(trackId), this.#layout, this.#context, ); @@ -557,7 +433,6 @@ export class LiveControlEdits { submix(submixId: string): SubmixEdits { return new SubmixEdits( id(submixId, "a submix ID"), - this.#submixes.get(submixId), this.#layout, this.#context, ); @@ -566,14 +441,14 @@ export class LiveControlEdits { /** * Either kind of strip by its ID -- a track, else a submix (strip IDs are unique across both) -- * for an edit every strip shares, such as arming an effect observation. It never offers `solo`. - * Observation addresses a strip by its index, so an ID that is neither is refused here. + * An ID that is neither is refused here. */ strip(stripId: string): StripEdits { if (this.#tracks.has(stripId)) return this.track(stripId); if (this.#submixes.has(stripId)) return this.submix(stripId); throw new MisoUsageError( `the compiled session has no track or submix '${stripId}'; expected one of ` - + `${[...this.#tracks.keys(), ...this.#submixes.keys()].join(", ")}`, + + `${[...this.#tracks, ...this.#submixes].join(", ")}`, ); } @@ -605,18 +480,15 @@ export class LiveControlEdits { */ export class EffectEdits extends EffectControlBuilders { readonly #stripId: string; - readonly #stripIndex: number | undefined; readonly #rack: LiveControlRack; - readonly #effectIndex: number | undefined; readonly #stableId: string | undefined; readonly #set: ControlSetter; protected readonly effectId: E; /** - * `instance.stableId` is the instance's console slot ID or insert ID, which the control address - * names it by; `effectIndex` is its live index, which observation records name it by. Either may - * be unknown: an instance named by ID without a session has no index, and one named by index - * without a session has no ID. + * `instance.stableId` is the instance's console slot ID or insert ID, which a control address and + * an observation record name it by. It is unknown for an instance named by index without a + * session to resolve the index against. */ constructor( instance: EffectInstance, @@ -624,9 +496,7 @@ export class EffectEdits extends EffectControlBuilders { ) { super(); this.#stripId = instance.stripId; - this.#stripIndex = instance.stripIndex; this.#rack = instance.rack; - this.#effectIndex = instance.effectIndex; this.#stableId = instance.stableId; this.effectId = instance.effectId; this.#set = set; @@ -652,29 +522,18 @@ export class EffectEdits extends EffectControlBuilders { options?: SetControlOptions, ): ControlEditSet { // The row is one of this effect's, by the machine name the generated table gives it; the - // instance knows its effect, so the address carries the row's number. The edit also keeps the - // effect it was typed for, so a send after a rebuild checks it against the core's header. - const section = this.#section(); - return noteRow( - this.#set( - { - owner: { kind: "strip", id: this.#stripId }, - section, - control: effectControlId(this.effectId, control), - lane, - }, - value, - options, - ), + // instance knows its effect, so the address carries the row's number and the effect it was + // resolved for, which the core holds the edit to (#1678). + return this.#set( { - stripId: this.#stripId, - section: section.kind === "console" - ? { kind: "console", slot: section.slot } - : { kind: "inserts", effect: (section as { readonly effect: string }).effect }, - name: control, - effectId: this.effectId, - strict: true, + owner: { kind: "strip", id: this.#stripId }, + section: this.#section(), + control: effectControlId(this.effectId, control), + lane, + expectedEffect: this.effectId, }, + value, + options, ); } @@ -684,18 +543,16 @@ export class EffectEdits extends EffectControlBuilders { if (observation === undefined || !observation.subscribable) { throw new MisoUsageError(`${this.effectId} has no subscribable tap '${String(tap)}'`); } - if (this.#stripIndex === undefined || this.#effectIndex === undefined) { + const stable = this.#stableId; + if (stable === undefined) { throw new MisoUsageError( - `an observation record names its effect by index, and this ${this.effectId} has none: ` - + "address it with liveControls().withSession(session) or effect(rack, index, effectId)", + `an observation record names its effect by its stable ID, and this ${this.effectId} was ` + + `addressed by index with nothing to resolve it against: ${NO_LAYOUT}`, ); } - return observationEdit(armed ? "observeSubscribe" : "observeUnsubscribe", this.#stripId, this.#stripIndex, { - rack: RACKS[this.#rack], - effectIndex: this.#effectIndex, - parameterId: observation.id, - smoothingSamples: requireU32(windowBlocks, "windowBlocks"), - }); + return observationEdit(armed, { + stripId: this.#stripId, rack: this.#rack, effectId: stable, tapId: observation.id, + }, windowBlocks); } } @@ -762,20 +619,6 @@ export class EngineLiveControls { ); } - /** - * Follow the core's control list: read the structure epoch, and when a rebuild moved it, take the - * list's strips and effects (#1562b-K). Builders made afterwards address the new order. A - * transport without a list keeps the order these controls were made with. Resolves the structure - * epoch of the list it followed, or `undefined` for a transport without one. - */ - async syncStructure(): Promise { - const source = this.#transport.stripOrder; - if (source === undefined) return undefined; - const order = await source(); - this.edit.update(order); - return order.structureEpoch; - } - /** The engine's one transaction queue, which every value edit goes through. */ get queue(): ControlTransactionQueue { return this.#transport.queue; @@ -816,22 +659,7 @@ export class EngineLiveControls { return this.#submitObservation(edits, true); } - /** - * @internal Managed submission whose effect indices come from the plan that renders at - * `structureEpoch`. A record names its strip by the newest list and its effect by an index, and - * the core admits it against the newest plan; so when the newest list is not of that epoch, the - * indices belong to another plan and the record is refused with the retryable - * {@link structureBehindError} before it is sent. - */ - async submitManagedAt(structureEpoch: bigint | undefined, edits: readonly LaneEdit[]): Promise { - return this.#submitObservation(edits, true, structureEpoch); - } - - async #submitObservation( - edits: readonly LaneEdit[], - managed: boolean, - structureEpoch?: bigint, - ): Promise { + async #submitObservation(edits: readonly LaneEdit[], managed: boolean): Promise { if (edits.length === 0 || edits.length > ABI_LAYOUT.constants.maximumCommandRecords) { throw new MisoUsageError( `an observation submit must contain 1..${ABI_LAYOUT.constants.maximumCommandRecords} edits`, @@ -842,29 +670,7 @@ export class EngineLiveControls { } const beforeSubmit = this.#beforeSubmit?.(edits, managed); if (beforeSubmit !== undefined) await beforeSubmit; - // A record names its strip by index, and a rebuild can move the strips: name each again from the - // core's list as it is now. - const source = this.#transport.stripOrder; - let sent = edits; - if (source !== undefined) { - const order = await source(); - if (structureEpoch !== undefined && order.structureEpoch !== structureEpoch) { - throw structureBehindError( - `the records were addressed at structure epoch ${structureEpoch}, and the control list is at ${order.structureEpoch}`, - ); - } - this.edit.update(order); - const strips = [...order.tracks, ...order.submixes]; - sent = edits.map((edit) => { - const stripId = OBSERVED_STRIP.get(edit); - const index = stripId === undefined ? -1 : strips.indexOf(stripId); - if (stripId === undefined || index < 0 || index === edit.trackIndex) return edit; - const moved: LaneEdit = Object.freeze({ ...edit, trackIndex: index }); - OBSERVED_STRIP.set(moved, stripId); - return moved; - }); - } - const report = await this.#transport.observe(sent); + const report = await this.#transport.observe(edits); const resultConsistent = report.ok === (report.result === 0); const wholeBatch = report.ok ? report.admitted === edits.length diff --git a/sdk/src/core/observation-subscriptions.ts b/sdk/src/core/observation-subscriptions.ts index dc2156bfa..1774d2bd6 100644 --- a/sdk/src/core/observation-subscriptions.ts +++ b/sdk/src/core/observation-subscriptions.ts @@ -2,7 +2,7 @@ import { ABI_LAYOUT } from "../generated/abi.ts"; import { constantValue } from "./abi.ts"; import { CATALOG } from "../generated/catalog.ts"; import type { CommandReport } from "./boundary.ts"; -import { EngineLiveControls, structureBehindError } from "./live-controls.ts"; +import { EngineLiveControls, observationEdit } from "./live-controls.ts"; import { MisoEngineError, MisoUsageError } from "./errors.ts"; import { MAXIMUM_OBSERVATION_READS, @@ -46,14 +46,6 @@ import type { EngineCallResult } from "./boundary.ts"; */ const MAXIMUM_ADMISSION_RETRIES = 8; -/** - * How many renders a subscribe, update or close waits through for the plan that renders to catch - * up with the newest committed one, so that the map's effect indices and the list's strip indices - * belong to one plan. A commit is adopted within a block or two, so more than a few means commits - * keep arriving faster than renders. - */ -const MAXIMUM_STRUCTURE_WAITS = 8; - /** The bounded configuration accepted by `subscribeObservations`. */ export interface ObservationSubscriptionLimits { readonly maximumHandles?: number; @@ -157,10 +149,9 @@ export interface ObservationSubscription { * engine refuses at once, so the caller renders and calls again. The browser engine first waits * for a render, up to `requestDeadlineMs`, and ends in the same error when none comes. * - * The same holds between a rebuild's commit and the block that adopts the replacement plan: the - * records of a subscribe, update or close name effects of the plan that renders, and the core - * admits them against the newest plan, so none is sent until a render has adopted it. The wait - * ends in the typed, retryable `backpressure` error (`sdk.observation.structure_behind`). + * A record names its tap by stable key (the strip, the effect and the tap), and the core resolves + * it on the newest committed plan, so a record sent between a rebuild's commit and the block that + * adopts the replacement plan arms the tap it names. */ update(request: ObservationSubscriptionRequest): Promise; close(): Promise; @@ -251,8 +242,6 @@ interface BindingState { readonly baseKey: string; readonly selection: ObservationSelection; readonly nativeSelection: ObservationSelection; - /** The owner's address as the newest map gave it: a rebuild can move the effect's index. */ - binding: ObservationBinding; readonly windowBlocks: number; refs: number; latest: ObservationReadResult | undefined; @@ -457,10 +446,18 @@ function projectRow(row: ObservationReadResult, channels: ObservationChannels): } function commandFailure(message: string, report: CommandReport): MisoEngineError { - return new MisoEngineError(message, { + // The result code alone does not say which rule refused the record, so the error carries the + // reason as its diagnostic code and the message names the reason and the record. `observationLimit` + // (#1676) is the cap on armed taps: no render makes room, so it is never resent, and the message + // says what does. + const hint = report.reasonName === "observationLimit" + ? " (the cap on armed observation taps is full; unsubscribe another tap first)" + : ""; + return new MisoEngineError(`${message}: ${report.reasonName} at record ${report.rejectedIndex}${hint}`, { phase: report.code === "wrongState" ? "lifecycle" : "output", code: report.code, result: report.result, + diagnostics: [{ code: report.reasonName, path: "" }], }); } @@ -627,7 +624,7 @@ export class ObservationSubscriptionOwner { throw new MisoUsageError(`observation subscriptions are capped at ${this.#subscriptionLimits.maximumHandles}`); } await this.#waitForPoll(); - const settled = await this.#settle(epoch, (map) => { + const settled = await this.#prepare(epoch, (map) => { const entries = normalized.configuration.selections.map((selection) => mapSelection(map, selection, normalized.configuration.windowBlocks)); if (entries.some((entry) => { @@ -647,9 +644,9 @@ export class ObservationSubscriptionOwner { const { liveControls } = settled; const preflight = await this.#preflight(newEntries, epoch); const edits = newEntries.map((entry) => - this.#edit(liveControls!, entry, true, normalized.configuration.windowBlocks)); + this.#edit(entry, true, normalized.configuration.windowBlocks)); const armRevision = await this.#committedRevision(liveControls); - const report = await this.#submit(liveControls, edits, epoch, settled.structureEpoch); + const report = await this.#submit(liveControls, edits, epoch); this.#assertEpoch(epoch); const appliedAtSample = report?.appliedAtSample ?? this.#existingSample(entries); const state = this.#newHandle( @@ -662,7 +659,6 @@ export class ObservationSubscriptionOwner { baseKey: entry.baseKey, selection: entry.selection, nativeSelection: entry.nativeSelection, - binding: entry.binding, windowBlocks: normalized.configuration.windowBlocks, refs: 1, latest: row === undefined ? undefined : pendingRow(row), @@ -688,8 +684,7 @@ export class ObservationSubscriptionOwner { this.#assertHandle(state); const epoch = this.#epoch; await this.#waitForPoll(); - const settled = await this.#settle(epoch, (map) => { - this.#remap(map); + const settled = await this.#prepare(epoch, (map) => { const entries = normalized.configuration.selections.map((selection) => mapSelection(map, selection, normalized.configuration.windowBlocks)); const currentByKey = new Map(state.entries.map((entry) => [entry.key, entry] as const)); @@ -729,13 +724,11 @@ export class ObservationSubscriptionOwner { })); const preflight = await this.#preflight(preflightEntries, epoch); const edits: LaneEdit[] = [ - ...disarm.map((entry) => this.#edit(liveControls!, entry, false, entry.windowBlocks)), - ...newEntries.map((entry) => this.#edit( - liveControls!, entry, true, normalized.configuration.windowBlocks, - )), + ...disarm.map((entry) => this.#edit(entry, false, entry.windowBlocks)), + ...newEntries.map((entry) => this.#edit(entry, true, normalized.configuration.windowBlocks)), ]; const armRevision = await this.#committedRevision(liveControls); - const report = await this.#submit(liveControls, edits, epoch, settled.structureEpoch); + const report = await this.#submit(liveControls, edits, epoch); this.#assertEpoch(epoch); const appliedAtSample = report?.appliedAtSample ?? this.#existingSample(entries); release.forEach((entry) => this.#bindings.delete(entry.key)); @@ -750,7 +743,6 @@ export class ObservationSubscriptionOwner { baseKey: entry.baseKey, selection: entry.selection, nativeSelection: entry.nativeSelection, - binding: entry.binding, windowBlocks: normalized.configuration.windowBlocks, refs: 1, latest: row === undefined ? undefined : pendingRow(row), @@ -794,27 +786,15 @@ export class ObservationSubscriptionOwner { this.#assertHandle(state); const epoch = this.#epoch; await this.#waitForPoll(); - let disarm = state.entries.filter((entry) => { + const disarm = state.entries.filter((entry) => { const binding = this.#bindings.get(entry.key); return binding !== undefined && binding.refs === 1; }); this.#assertCommandSize(disarm.length); - let liveControls: EngineLiveControls | undefined; - let structureEpoch: bigint | undefined; - if (disarm.length !== 0) { - // The records name the tap by the indices of the plan that renders now, which a rebuild can - // have changed since the subscription was made. - const held = disarm; - const settled = await this.#settle(epoch, (map) => { - this.#remap(map); - return { records: held.length }; - }); - liveControls = settled.liveControls; - structureEpoch = settled.structureEpoch; - disarm = state.entries.filter((entry) => held.some((kept) => kept.key === entry.key)); - } - const edits = disarm.map((entry) => this.#edit(liveControls!, entry, false, entry.windowBlocks)); - const report = await this.#submit(liveControls, edits, epoch, structureEpoch); + const liveControls = disarm.length === 0 ? undefined : await this.#transport.liveControls(); + this.#assertEpoch(epoch); + const edits = disarm.map((entry) => this.#edit(entry, false, entry.windowBlocks)); + const report = await this.#submit(liveControls, edits, epoch); this.#assertEpoch(epoch); if (report !== undefined && !report.ok) throw commandFailure("observation close was refused", report); disarm.forEach((entry) => this.#bindings.delete(entry.key)); @@ -923,27 +903,6 @@ export class ObservationSubscriptionOwner { this.#stopTimerIfIdle(); } - /** - * Take the newest map's address for every held tap. A rebuild can move an effect to another index - * or strip; a tap is held by its stable IDs, and only the records that arm and disarm it name the - * index. A tap the map no longer has keeps its last address: the core ends it with its own event. - */ - #remap(map: ObservationMap): void { - const current = (selection: ObservationSelection): ObservationBinding | undefined => map.bindings.find((candidate) => - candidate.trackId === selection.trackId && candidate.rack === selection.rack - && candidate.effectSlotId === selection.effectSlotId); - for (const binding of this.#bindings.values()) { - const fresh = current(binding.selection); - if (fresh !== undefined) binding.binding = fresh; - } - for (const state of this.#handles.values()) { - state.entries = Object.freeze(state.entries.map((entry) => { - const fresh = current(entry.selection); - return fresh === undefined || fresh === entry.binding ? entry : Object.freeze({ ...entry, binding: fresh }); - })); - } - } - /** Invalidate only the spectrum lifetime after its analysis transport has failed. */ invalidateSpectrum(): void { for (const state of this.#spectrumHandles.values()) state.closed = true; @@ -1038,72 +997,39 @@ export class ObservationSubscriptionOwner { return result; } - #edit( - liveControls: EngineLiveControls, - entry: ResolvedSelection, - armed: boolean, - windowBlocks: number, - ): LaneEdit { - // `effect` and tap names come from the generated catalog after map validation. The live - // controls' generic type cannot express a runtime catalog row, so this is the one narrow cast - // at the seam. - // A binding's `trackId` names its strip, a track or a submix (#1214 MINOR-1). - return liveControls.edit.strip(entry.selection.trackId) - .effect(entry.selection.rack, entry.binding.effectIndex, entry.binding.nativeEffectId as never) - .observe(tapName(entry.binding, entry.selection.tapId) as never, armed, windowBlocks); + #edit(entry: ResolvedSelection, armed: boolean, windowBlocks: number): LaneEdit { + // The record names its tap by stable key, which the selection already is: the strip, the + // console slot or insert, the tap. The core resolves it on the plan it admits the record + // against, so nothing here can address another effect after a rebuild (#1677). The catalog + // says whether the tap is subscribable at all, which is refused before anything is sent. + tapName(entry.binding, entry.selection.tapId); + return observationEdit(armed, { + stripId: entry.selection.trackId, + rack: entry.selection.rack, + effectId: entry.selection.effectSlotId, + tapId: entry.selection.tapId, + }, windowBlocks); } /** - * Read the map of the plan that renders and the newest control list until both name one structure - * epoch, so that the effect index of a record (from the map) and its strip index (from the list) - * are of the plan the core admits the record against: the newest (M1 of the batch 12b review). + * Read the map of the plan that renders and resolve the request against it. `resolve` says how + * many records the request needs; one that needs none returns at once, with no live controls. * - * `resolve` maps the request against a map and says how many records it needs. A request that - * needs none returns at once, with no live controls and no wait. Between a rebuild's commit and - * the block that adopts its replacement the map is older than the list: a browser engine waits - * for a render (within its render-wait bound) and reads again, and an engine that renders only - * when its caller does throws the retryable `backpressure` error at once. Either way, a map that - * does not catch up in {@link MAXIMUM_STRUCTURE_WAITS} renders or the bound refuses with that - * error, and no misaddressed record is sent. + * The map describes the plan that renders and validates the selection (the effect and its tap + * exist, a read can reach them). It does not address a record: the record names the tap by stable + * key and the core resolves it on the newest committed plan (#1677). */ - async #settle( + async #prepare( epoch: bigint, resolve: (map: ObservationMap) => T, - ): Promise<{ - readonly resolved: T; - readonly liveControls: EngineLiveControls | undefined; - /** The epoch the records are addressed at, or `undefined` when nothing could be compared. */ - readonly structureEpoch: bigint | undefined; - }> { - const wait = this.#transport.renderWait; - let deadline: number | undefined; - for (let waits = 0; ; waits += 1) { - // The position is read before the map, so a block rendered after the map was read counts. - const since = wait?.mark(); - since?.catch(() => undefined); - const map = await this.#transport.observationMap(); - this.#assertEpoch(epoch); - const resolved = resolve(map); - if (resolved.records === 0) return { resolved, liveControls: undefined, structureEpoch: undefined }; - const liveControls = await this.#transport.liveControls(); - this.#assertEpoch(epoch); - // A rebuild can have moved the strips: the records name them by the core's list as it is now. - const listEpoch = await liveControls.syncStructure(); - this.#assertEpoch(epoch); - if (map.structureEpoch === undefined || listEpoch === undefined || map.structureEpoch === listEpoch) { - return { resolved, liveControls, structureEpoch: listEpoch }; - } - const behind = structureBehindError( - `the map is of structure epoch ${map.structureEpoch} and the control list is at ${listEpoch}`, - ); - if (wait === undefined || since === undefined || waits >= MAXIMUM_STRUCTURE_WAITS) throw behind; - deadline ??= Date.now() + wait.boundMs; - const remaining = deadline - Date.now(); - if (remaining <= 0) throw behind; - const rendered = await wait.next(remaining, await since); - this.#assertEpoch(epoch); - if (!rendered) throw behind; - } + ): Promise<{ readonly resolved: T; readonly liveControls: EngineLiveControls | undefined }> { + const map = await this.#transport.observationMap(); + this.#assertEpoch(epoch); + const resolved = resolve(map); + if (resolved.records === 0) return { resolved, liveControls: undefined }; + const liveControls = await this.#transport.liveControls(); + this.#assertEpoch(epoch); + return { resolved, liveControls }; } /** @@ -1125,15 +1051,8 @@ export class ObservationSubscriptionOwner { liveControls: EngineLiveControls | undefined, edits: readonly LaneEdit[], epoch: bigint, - structureEpoch?: bigint, ): Promise { if (edits.length === 0) return undefined; - const managedAt = (liveControls as EngineLiveControls & { - readonly submitManagedAt?: ( - structureEpoch: bigint | undefined, - edits: readonly LaneEdit[], - ) => Promise; - } | undefined)?.submitManagedAt; const managedSubmit = (liveControls as EngineLiveControls & { readonly submitManaged?: (...edits: readonly LaneEdit[]) => Promise; } | undefined)?.submitManaged; @@ -1145,16 +1064,15 @@ export class ObservationSubscriptionOwner { // answers it first. A block rendered after it and before the refusal returns is then seen. const since = wait?.mark(); since?.catch(() => undefined); - const report = managedAt !== undefined - ? await managedAt.call(liveControls, structureEpoch, edits) - : managedSubmit === undefined - ? await liveControls!.submit(...edits) - : await managedSubmit.call(liveControls, ...edits); + const report = managedSubmit === undefined + ? await liveControls!.submit(...edits) + : await managedSubmit.call(liveControls, ...edits); if (report.ok) return report; // A submission names each tap once, so a refusal for room means a record of an earlier // submission still holds the tap's slot, and a render drains it. That refusal alone is flow - // control: it is typed and retryable. Anything else, a malformed or unbound request - // included, never succeeds on resend and is thrown at once. + // control: it is typed and retryable. Anything else, a malformed or unbound request or one + // past the cap on armed taps (`observationLimit`, #1676) included, never succeeds on resend + // and is thrown at once. if (report.reasonName !== "backpressure" || wait === undefined || attempt >= MAXIMUM_ADMISSION_RETRIES) { throw commandFailure("observation transaction was refused", report); diff --git a/sdk/src/core/observation.ts b/sdk/src/core/observation.ts index 406fb4ef8..799410b16 100644 --- a/sdk/src/core/observation.ts +++ b/sdk/src/core/observation.ts @@ -36,16 +36,11 @@ export interface ObservationBinding { readonly tapIds: readonly number[]; } -/** The current owner map used to resolve stable selections to numeric Wasm addresses. */ +/** + * The current owner map used to resolve stable selections to numeric Wasm addresses for a read. + * An observation record does not use it: it names its tap by stable key and the core resolves it. + */ export interface ObservationMap { - /** - * The structure epoch of the control list whose strip order `bindings` follow (#1562b): the plan - * that renders. A rebuild changes it at the block that adopts the replacement, so between the - * rebuild's commit and that block it is older than the newest list's, and a binding's effect - * index is not the one the core admits a record against. A transport that cannot say leaves it - * out, and nothing is checked against it. - */ - readonly structureEpoch?: bigint; readonly bindings: readonly ObservationBinding[]; } @@ -209,7 +204,6 @@ export function validateObservationSelections(selections: readonly ObservationSe export function enrichObservationMap( strips: readonly string[], raw: readonly RawObservationBinding[], - structureEpoch?: bigint, ): ObservationMap { const bindings = raw.map((binding, index) => { if (!Number.isSafeInteger(binding.trackIndex) || binding.trackIndex < 0 @@ -237,9 +231,7 @@ export function enrichObservationMap( tapIds: Object.freeze([...binding.tapIds]), }); }); - return Object.freeze(structureEpoch === undefined - ? { bindings: Object.freeze(bindings) } - : { structureEpoch, bindings: Object.freeze(bindings) }); + return Object.freeze({ bindings: Object.freeze(bindings) }); } /** diff --git a/sdk/src/core/writer.ts b/sdk/src/core/writer.ts index b427db4a7..3469e14fc 100644 --- a/sdk/src/core/writer.ts +++ b/sdk/src/core/writer.ts @@ -10,7 +10,7 @@ import { import type { ControlFrameEdit } from "./control-frame.ts"; import type { ControlAppliedReport } from "./control-queue.ts"; import { MisoControlRefusal, MisoEngineError, MisoUsageError } from "./errors.ts"; -import { laneEditWord } from "../internal/ramp.ts"; +import { requireU32 } from "../internal/ramp.ts"; /** * The live-control writer (issue #243, adopted ruling 5462139867 finding 5). @@ -58,22 +58,19 @@ import { laneEditWord } from "../internal/ramp.ts"; */ export interface LaneEdit { readonly kind: "observeSubscribe" | "observeUnsubscribe"; - /** The strip index: the tracks, then the submixes. */ - readonly trackIndex: number; /** * The record's rack byte: `1` inserts, `3` console (S1c). The retired `0` and `2` are refused by * the engine with `unknownRack`. */ readonly rack: number; - /** `255`, not applicable. */ - readonly channel: number; - /** A console slot's index in the session's slot order, or an insert's index on its strip. */ - readonly effectIndex?: number; + /** The strip's ID, a track's or a submix's. */ + readonly stripId: string; + /** The console slot's ID or the insert's ID. */ + readonly effectId: string; /** The tap's ID. */ - readonly parameterId?: number; + readonly tapId: number; /** The subscription window in blocks, `0` for the default. Any `u32`. */ - readonly smoothingSamples?: number | undefined; - readonly values: readonly [number, number, number, number]; + readonly windowBlocks: number; } export interface FlushOutcome { @@ -188,28 +185,50 @@ function isControlEdit(edit: ControlFrameEdit | LaneEdit): edit is ControlFrameE */ function coalescingKey(edit: ControlFrameEdit | LaneEdit): string { if (isControlEdit(edit)) return controlEditKey(edit); + // The key is the tap's stable key; the strip and effect IDs are length-prefixed so no pair of + // IDs can spell another pair. return [ - edit.kind, edit.trackIndex, edit.rack, edit.channel, - edit.effectIndex ?? 0, edit.parameterId ?? 0, + edit.kind, edit.rack, edit.stripId.length, edit.stripId, edit.effectId.length, edit.effectId, + edit.tapId, ].join("/"); } +const ID_ENCODER = new TextEncoder(); +const ID_FIELD_BYTES = 128; + +/** One record ID's UTF-8 bytes; the field holds 127 of them and a zero after the last. */ +function recordId(id: string, what: string): Uint8Array { + if (typeof id !== "string") throw new MisoUsageError(`${what} must be a string`); + const bytes = ID_ENCODER.encode(id); + if (bytes.length >= ID_FIELD_BYTES) { + throw new MisoUsageError(`${what} is ${bytes.length} bytes; a record holds at most ${ID_FIELD_BYTES - 1}`); + } + return bytes; +} + +/** Check that an observation edit can be encoded; the core judges what it names. */ +function checkLaneEdit(edit: LaneEdit): void { + recordId(edit.stripId, "an observation record's strip ID"); + recordId(edit.effectId, "an observation record's effect ID"); + requireU32(edit.tapId, "tapId"); + requireU32(edit.windowBlocks, "windowBlocks"); +} + export function encodeLaneEdits(edits: readonly LaneEdit[]): Uint8Array { const records = new Uint8Array(edits.length * RECORD_BYTES); const view = new DataView(records.buffer); edits.forEach((edit, index) => { const base = index * RECORD_BYTES; + const strip = recordId(edit.stripId, "an observation record's strip ID"); + const effect = recordId(edit.effectId, "an observation record's effect ID"); view.setUint8(base + offsetOf("kind"), kindValue(edit.kind)); view.setUint8(base + offsetOf("rack"), edit.rack); - view.setUint8(base + offsetOf("channel"), edit.channel); - view.setUint32(base + offsetOf("trackIndex"), edit.trackIndex, true); - view.setUint32(base + offsetOf("effectIndex"), edit.effectIndex ?? 0, true); - view.setUint32(base + offsetOf("parameterId"), edit.parameterId ?? 0, true); - view.setUint32(base + offsetOf("smoothingSamples"), laneEditWord(edit), true); - const values = offsetOf("values"); - edit.values.forEach((value, slot) => { - view.setFloat32(base + values + slot * 4, value, true); - }); + view.setUint8(base + offsetOf("stripIdBytes"), strip.length); + view.setUint8(base + offsetOf("effectIdBytes"), effect.length); + view.setUint32(base + offsetOf("tapId"), requireU32(edit.tapId, "tapId"), true); + view.setUint32(base + offsetOf("windowBlocks"), requireU32(edit.windowBlocks, "windowBlocks"), true); + records.set(strip, base + offsetOf("stripId")); + records.set(effect, base + offsetOf("effectId")); }); return records; } @@ -337,7 +356,7 @@ export class LiveControlWriter { ); } if (isControlEdit(edit)) validateControlEdit(edit); - else laneEditWord(edit); + else checkLaneEdit(edit); const key = coalescingKey(edit); if (this.#pending.has(key)) this.#coalesced += 1; // Delete before set so a superseded edit also moves to the back of the insertion order: the diff --git a/sdk/src/generated/abi.ts b/sdk/src/generated/abi.ts index 7bd054526..af9c887dd 100644 --- a/sdk/src/generated/abi.ts +++ b/sdk/src/generated/abi.ts @@ -1402,7 +1402,7 @@ export const ABI_LAYOUT = deepFreeze( } }, "commandRecord": { - "bytes": 48, + "bytes": 272, "endianness": "little", "fields": [ { @@ -1416,49 +1416,39 @@ export const ABI_LAYOUT = deepFreeze( "type": "u8" }, { - "name": "channel", + "name": "stripIdBytes", "offset": 2, "type": "u8" }, { - "name": "reserved0", + "name": "effectIdBytes", "offset": 3, "type": "u8" }, { - "name": "trackIndex", + "name": "tapId", "offset": 4, "type": "u32" }, { - "name": "effectIndex", + "name": "windowBlocks", "offset": 8, "type": "u32" }, { - "name": "parameterId", + "name": "reserved0", "offset": 12, "type": "u32" }, { - "name": "smoothingSamples", + "name": "stripId", "offset": 16, - "type": "u32" - }, - { - "name": "reserved1", - "offset": 20, - "type": "u32" - }, - { - "name": "values", - "offset": 24, - "type": "f32[4]" + "type": "u8[128]" }, { - "name": "reserved2", - "offset": 40, - "type": "u8[8]" + "name": "effectId", + "offset": 144, + "type": "u8[128]" } ] }, @@ -1635,6 +1625,10 @@ export const ABI_LAYOUT = deepFreeze( { "value": 11, "name": "observationUnbound" + }, + { + "value": 15, + "name": "observationLimit" } ], "racks": [ diff --git a/sdk/src/generated/catalog.ts b/sdk/src/generated/catalog.ts index 0977419ac..16b7d5a57 100644 --- a/sdk/src/generated/catalog.ts +++ b/sdk/src/generated/catalog.ts @@ -16,7 +16,7 @@ export const CATALOG = deepFreeze( { "schema": "miso.web.parameter-metadata.v1", "abiVersion": 65536, - "commandRecordBytes": 48, + "commandRecordBytes": 272, "maximumCommandRecords": 256, "commandKinds": [ { @@ -80,6 +80,10 @@ export const CATALOG = deepFreeze( { "value": 11, "name": "observationUnbound" + }, + { + "value": 15, + "name": "observationLimit" } ], "observationVocabularies": { diff --git a/sdk/src/generated/control-builders.ts b/sdk/src/generated/control-builders.ts index 7704446de..3f95da88a 100644 --- a/sdk/src/generated/control-builders.ts +++ b/sdk/src/generated/control-builders.ts @@ -191,11 +191,10 @@ export abstract class StripEdits extends StripAddressing { export class TrackEdits extends StripEdits { constructor( trackId: string, - trackIndex: number | undefined, layout: LiveControlLayout | undefined, context: StripContext, ) { - super("track", trackId, trackIndex, layout, context); + super("track", trackId, layout, context); } /** `solo`: Solo. */ @@ -209,17 +208,15 @@ export class TrackEdits extends StripEdits { /** * A submix strip's live edits: every strip edit and no `solo` (a bus is solo-safe; the core refuses - * the solo of a submix). `LiveControlEdits.submix(id)` also finds the submix's strip index, - * `T + j`, for observation. + * the solo of a submix). */ export class SubmixEdits extends StripEdits { constructor( submixId: string, - stripIndex: number | undefined, layout: LiveControlLayout | undefined, context: StripContext, ) { - super("submix", submixId, stripIndex, layout, context); + super("submix", submixId, layout, context); } } diff --git a/sdk/src/headless/engine.ts b/sdk/src/headless/engine.ts index 6c14e63db..6dd5fc94d 100644 --- a/sdk/src/headless/engine.ts +++ b/sdk/src/headless/engine.ts @@ -42,7 +42,7 @@ import type { ControlListTransport, ControlState, } from "../core/control-list.ts"; -import { EngineLiveControls, effectRowResolver, sessionDescribesStrips } from "../core/live-controls.ts"; +import { EngineLiveControls, sessionDescribesStrips } from "../core/live-controls.ts"; import { decodeObservationEnded } from "../core/observation-ended.ts"; import { SessionBuilder } from "../core/session.ts"; import { MisoEngineError, MisoUsageError } from "../core/errors.ts"; @@ -294,7 +294,6 @@ export class OfflineEngine { queue: this.controlTransactions(), observe: (edits) => this.submitCommands(encodeLaneEdits(edits), edits.length), // The boundary caches the order by structure epoch: a probe each call, the list when it moved. - stripOrder: () => this.#boundary.stripOrder(), order: this.#boundary.stripOrder(), }, (edits, managed) => this.#observationSubscriptions?.beforeLiveControlSubmit(edits, managed), @@ -327,8 +326,6 @@ export class OfflineEngine { requestIds, limits, readRevision: () => this.#boundary.controlRevision(), - // An edit that names an effect's row is checked against the core's list before it is sent. - prepare: effectRowResolver(() => this.#boundary.stripOrder()), }, ); } diff --git a/sdk/src/internal/ramp.ts b/sdk/src/internal/ramp.ts index 8cf2ebee8..b4f8c17be 100644 --- a/sdk/src/internal/ramp.ts +++ b/sdk/src/internal/ramp.ts @@ -1,5 +1,4 @@ import { MisoUsageError } from "../core/errors.ts"; -import type { LaneEdit } from "../core/writer.ts"; /** * Internal word rules of the observation command record. This module is deliberately absent from @@ -7,8 +6,7 @@ import type { LaneEdit } from "../core/writer.ts"; * * Value edits carry a ramp as `rampSamples` of a `SET_CONTROL` edit (`control-frame.ts`); the * session-default marker of the retired 48-byte value records is gone with them, and the core - * resolves an absent ramp. What is left of a {@link LaneEdit} is the observation records (kinds 7 - * and 8), whose `smoothingSamples` word is the subscription window in blocks. + * resolves an absent ramp. */ /** A `u32`, as `DataView.setUint32` needs it: it would wrap `-1` and truncate `1.5`. */ @@ -18,8 +16,3 @@ export function requireU32(samples: number, name = "smoothingSamples"): number { } return samples; } - -/** The `smoothingSamples` word an observation record puts on the wire: its window, or `0`. */ -export function laneEditWord(edit: LaneEdit): number { - return requireU32(edit.smoothingSamples ?? 0); -} diff --git a/sdk/test/browser-defaults-evals.mjs b/sdk/test/browser-defaults-evals.mjs index ce6577116..deb26a751 100644 --- a/sdk/test/browser-defaults-evals.mjs +++ b/sdk/test/browser-defaults-evals.mjs @@ -488,12 +488,15 @@ test("a browser engine's live controls address a submix by its strip index", asy assert.ok(ids[0].includes("bus"), "the transaction names the bus by its stable ID"); assert.equal((await controls.submit(controls.edit.submix("aaa").mute(true))).ok, true); assert.ok(new TextDecoder().decode(frames[4]).includes("aaa")); - // Observation records address a strip by index: the track, then the submixes in the host's order. - const record = controls.edit.submix("bus").effect("inserts", 0, "miso.compressor") + // Observation records name their tap by stable key: the strip's ID, the insert's ID and the tap. + const record = controls.edit.submix("bus").insert("comp", "miso.compressor") .observe("Gain Reduction", true, 1); - assert.equal(record.trackIndex, 2, "bus is SessionMap.submixes[1], strip index 1 + 1"); - assert.equal(controls.edit.submix("aaa").effect("inserts", 0, "miso.compressor") - .observe("Gain Reduction", true, 1).trackIndex, 1, "aaa is SessionMap.submixes[0], strip index 1 + 0"); + assert.deepEqual( + { stripId: record.stripId, effectId: record.effectId, rack: record.rack, windowBlocks: record.windowBlocks }, + { stripId: "bus", effectId: "comp", rack: 1, windowBlocks: 1 }, + ); + assert.equal(controls.edit.submix("aaa").insert("comp", "miso.compressor") + .observe("Gain Reduction", true, 1).stripId, "aaa", "no position of any plan is in the record"); } finally { await engine.close(); } diff --git a/sdk/test/capability-evals.mjs b/sdk/test/capability-evals.mjs index c887aefcb..1115f6e63 100644 --- a/sdk/test/capability-evals.mjs +++ b/sdk/test/capability-evals.mjs @@ -236,13 +236,11 @@ describe("issue 321 -- complete headless ABI capability parity", () => { }); writer.stage({ kind: "observeSubscribe", - trackIndex: 0, - rack: 3, // console slot 0 - channel: 255, - effectIndex: 0, - parameterId: 1, - smoothingSamples: meterBlocks, - values: [0, 0, 0, 0], + rack: 3, // console slot "compressor" + stripId: engine.sessionMap().tracks[0], + effectId: "compressor", + tapId: 1, + windowBlocks: meterBlocks, }); assert.equal((await writer.flush()).admitted, 1); @@ -400,7 +398,7 @@ describe("issue 321 -- complete headless ABI capability parity", () => { } }); - test("a selected read of a bus tap names the bus, unarmed and then armed at its strip index", async () => { + test("a selected read of a bus tap names the bus, unarmed and then armed by its stable key", async () => { // Issue #1213 (K2 verdict requirement (a)). The SDK resolves a bus tap to its strip index // `T + j`, and host-web must resolve that index per strip -- tracks, then submixes. Red if the // host looks the index up among the tracks only: the read then fails with `invalidArgument`. @@ -429,13 +427,11 @@ describe("issue 321 -- complete headless ABI capability parity", () => { }); writer.stage({ kind: "observeSubscribe", - trackIndex: busIndex, rack: binding.rack === "console" ? 3 : 1, - channel: 255, - effectIndex: 0, - parameterId: binding.tapIds[0], - smoothingSamples: 1, - values: [0, 0, 0, 0], + stripId: "bus", + effectId: "bus-comp", + tapId: binding.tapIds[0], + windowBlocks: 1, }); assert.equal((await writer.flush()).admitted, 1); for (let block = 0; block < 4; block += 1) { @@ -444,7 +440,7 @@ describe("issue 321 -- complete headless ABI capability parity", () => { } const [armed] = engine.readObservations(selection); assert.equal(armed.trackId, "bus"); - assert.notEqual(armed.status, "unarmed", "kind 7 at the bus's strip index armed its tap"); + assert.notEqual(armed.status, "unarmed", "kind 7 naming the bus's strip armed its tap"); } finally { engine.dispose(); } diff --git a/sdk/test/control-builders-evals.mjs b/sdk/test/control-builders-evals.mjs index 4b2d2274a..d8a59c8a8 100644 --- a/sdk/test/control-builders-evals.mjs +++ b/sdk/test/control-builders-evals.mjs @@ -191,7 +191,7 @@ describe("#1564B gate 3 -- every effect row", () => { }); for (const [rack, instance, section] of instances) { - test(`${rack}: parameter, bypass and link mode stage what set stages`, () => { + test(`${rack}: parameter, bypass and link mode stage what set stages, with the effect they were written for (#1678)`, () => { const strip = edit.track("t"); let checked = 0; for (const [effectId, rows] of Object.entries(EFFECT_PARAMETER_ROWS)) { @@ -211,12 +211,12 @@ describe("#1564B gate 3 -- every effect row", () => { ]) { assert.deepEqual( builders.parameter(name, given, options).edits, - edit.set({ owner, section, control, lane }, scalar, rampSamples === undefined ? {} : { rampSamples }).edits, + edit.set({ owner, section, control, lane, expectedEffect: effectId }, scalar, rampSamples === undefined ? {} : { rampSamples }).edits, `${effectId}.${name} ${JSON.stringify(options)}`, ); assert.deepEqual( builders.parameter({ key: name, value: given, ...options }).edits, - edit.set({ owner, section, control, lane }, scalar, rampSamples === undefined ? {} : { rampSamples }).edits, + edit.set({ owner, section, control, lane, expectedEffect: effectId }, scalar, rampSamples === undefined ? {} : { rampSamples }).edits, ); checked += 1; } @@ -225,7 +225,7 @@ describe("#1564B gate 3 -- every effect row", () => { for (const [enabled, scalar] of [[true, 1], [false, 0]]) { assert.deepEqual( builders.bypass(enabled).edits, - edit.set({ owner, section, control: numeric(effectId, "bypass"), lane: "both" }, scalar).edits, + edit.set({ owner, section, control: numeric(effectId, "bypass"), lane: "both", expectedEffect: effectId }, scalar).edits, `${effectId} bypass`, ); } @@ -234,7 +234,7 @@ describe("#1564B gate 3 -- every effect row", () => { for (const [label, scalar] of linkMode.choices) { assert.deepEqual( builders.linkMode(label).edits, - edit.set({ owner, section, control: numeric(effectId, linkMode.control), lane: "both" }, scalar).edits, + edit.set({ owner, section, control: numeric(effectId, linkMode.control), lane: "both", expectedEffect: effectId }, scalar).edits, `${effectId} link mode ${label}`, ); } diff --git a/sdk/test/control-frame-evals.mjs b/sdk/test/control-frame-evals.mjs index ff786c1bb..ecd017ee5 100644 --- a/sdk/test/control-frame-evals.mjs +++ b/sdk/test/control-frame-evals.mjs @@ -49,6 +49,7 @@ function editOf(row) { section: row.section, control: row.control, lane: row.lane, + ...(row.expectedEffect === null ? {} : { expectedEffect: row.expectedEffect }), }, value: floatFromBits(row.valueBits), ...(row.rampSamples === null ? {} : { rampSamples: row.rampSamples }), @@ -80,6 +81,10 @@ describe("the transaction encoder equals the protocol codec's bytes", () => { [...seen((row) => row.rampSamples)].sort((a, b) => (a ?? -1) - (b ?? -1)), [null, 0, 480, 4294967295], ); + // #1678: an insert and a console edit carry the expected effect; the others carry none. + const withEffect = edits.filter((row) => row.expectedEffect !== null); + assert.deepEqual([...new Set(withEffect.map((row) => row.section.kind))].sort(), ["console", "inserts"]); + assert.ok(edits.some((row) => row.expectedEffect === null)); }); }); @@ -186,6 +191,17 @@ describe("the encoder refuses only what it cannot encode, before any byte", () = ...request, edits: [{ ...edit, address: { ...edit.address, lane: "middle" } }], }, + "expected effect on a builtin row": { + ...request, + edits: [{ ...edit, address: { ...edit.address, section: { kind: "builtins" }, expectedEffect: "miso.parametric-eq" } }], + }, + "expected effect a number": { + ...request, + edits: [{ + ...edit, + address: { ...edit.address, section: { kind: "inserts", effect: "eq" }, expectedEffect: 3 }, + }], + }, "control row fractional": { ...request, edits: [{ ...edit, address: { ...edit.address, control: 1.5 } }], diff --git a/sdk/test/control-queue-evals.mjs b/sdk/test/control-queue-evals.mjs index 2ff26f324..7f804cd4a 100644 --- a/sdk/test/control-queue-evals.mjs +++ b/sdk/test/control-queue-evals.mjs @@ -160,6 +160,33 @@ describe("one transaction in flight, the committed revision in every frame", () assert.equal(core.waiting, 0); }); + test("two edits of one row for different expected effects do not coalesce", async () => { + // Plausible defect: the coalescing key ignores `expectedEffect`, so a stale edit (written for an + // effect a rebuild replaced) and a fresh one on the same numeric row collapse into one, and the + // stale submit resolves `ok` as superseded, or the fresh value is dropped behind the stale one. + const core = fakeCore({ hold: true }); + const queue = new ControlTransactionQueue(core.send, { initialRevision: 0n }); + const insert = (expectedEffect, value) => ({ + address: { owner: vocal, section: { kind: "inserts", effect: "comp" }, control: 2, lane: "both", expectedEffect }, + value, + }); + const first = queue.submit([edit(vocal, 1, 0)]); + const stale = insert("miso.compressor", -10); + const fresh = insert("miso.gate", -20); + const staged = [queue.submit([stale]), queue.submit([fresh])]; + core.release(); + await first; + await settled(); + assert.equal( + hexOf(core.frames[1]), + hexOf(encodeControlTransaction({ requestId: 2n, expectedRevision: 1n, edits: [stale, fresh] })), + "both edits are sent, in staged order", + ); + core.release(); + await Promise.all(staged); + assert.deepEqual({ ...queue.stats }, { transactions: 2, coalescedEdits: 0, replacedSubmits: 0 }); + }); + test("edits of one submit go in one transaction, and an address staged again moves last", async () => { const core = fakeCore({ hold: true }); const queue = new ControlTransactionQueue(core.send, { initialRevision: 0n }); diff --git a/sdk/test/live-controls-evals.mjs b/sdk/test/live-controls-evals.mjs index 619b560d7..987000276 100644 --- a/sdk/test/live-controls-evals.mjs +++ b/sdk/test/live-controls-evals.mjs @@ -378,8 +378,13 @@ describe("#1563B -- a builder is a set of SET_CONTROL edits at the core's addres const byIndex = edits.track("t").effect("console", 0, "miso.compressor"); assert.throws(() => byIndex.parameter("threshold", -18), /stable ID/); assert.throws(() => byIndex.bypass(true), /stable ID/); - assert.throws(() => edits.track("t").console("slot", "miso.compressor") - .observe("Gain Reduction", true, 1), /names its effect by index/); + assert.throws(() => byIndex.observe("Gain Reduction", true, 1), /stable ID/); + // An observation record names its effect by the stable ID, which console() gives without a session. + const named = edits.track("t").console("slot", "miso.compressor").observe("Gain Reduction", true, 1); + assert.deepEqual( + { kind: named.kind, stripId: named.stripId, rack: named.rack, effectId: named.effectId, tapId: named.tapId }, + { kind: "observeSubscribe", stripId: "t", rack: 3, effectId: "slot", tapId: 1 }, + ); assert.throws(() => edits.track("t").insert(0, "miso.compressor"), /nothing to resolve an index against/); }); }); @@ -811,7 +816,7 @@ describe("#1563B -- the browser transport posts the same frames and decodes the try { const host = engineHost(engine); const controls = await createBrowserLiveControls(host, undefined, undefined, undefined); - const compressor = controls.edit.track("t").effect("console", 0, "miso.compressor"); + const compressor = controls.edit.track("t").console("compressor", "miso.compressor"); const report = await controls.submit(compressor.observe("Gain Reduction", true, 2)); assert.equal(report.ok, true); assert.equal(report.admitted, 1); @@ -1007,7 +1012,7 @@ describe("#1563B -- a torn acknowledgement is rejected after, never before, the }, }, ); - const compressor = liveControls.edit.track("t").effect("console", 0, "miso.compressor"); + const compressor = liveControls.edit.track("t").console("compressor", "miso.compressor"); await assert.rejects( () => liveControls.submit(compressor.observe("Gain Reduction", true, 1)), /violated whole-batch admission/, diff --git a/sdk/test/measurement-evals.mjs b/sdk/test/measurement-evals.mjs index 65baa2ab3..c1ae225a3 100644 --- a/sdk/test/measurement-evals.mjs +++ b/sdk/test/measurement-evals.mjs @@ -538,7 +538,7 @@ test("browser live controls retain the managed observation conflict hook", async windowBlocks: 1, }); const liveControls = await engine.liveControls(); - const manual = liveControls.edit.track("t").effect("inserts", 0, "miso.compressor") + const manual = liveControls.edit.track("t").insert("comp", "miso.compressor") .observe("Gain Reduction", false, 1); await assert.rejects(liveControls.submit(manual), /conflict/); assert.equal(commands.length, 1, "the manual conflicting edit is stopped by the existing hook"); @@ -552,10 +552,10 @@ test("browser live controls retain the managed observation conflict hook", async }); -test("the browser engine sends an observation record only for the map of the list's epoch (batch 12b M1)", async () => { +test("the browser engine sends an observation record that names its tap by key, whatever the map says (#1677)", async () => { // A rebuild is committed: the control list is already epoch 2 (comp moved from effect 1 to 0), // and the plan that renders still reports epoch 1 and comp at effect 1 until a block adopts the - // replacement. A record built from that map would name effect 1 of the newest plan. + // replacement. The record carries no index, so it is sent at once and names the same tap. const host = fakeHost(); const strips = { tracks: ["t"], submixes: [], structureEpoch: 1n }; let rendered = 0n; @@ -575,10 +575,9 @@ test("the browser engine sends an observation record only for the map of the lis }, onControlEvent() { return () => {}; }, async observationMap() { - const adopted = rendered >= 3n; return { - tag: "miso.observationmap.v1", result: 0, structureEpoch: adopted ? 2 : 1, - bindings: [{ trackIndex: 0, rack: 1, effectIndex: adopted ? 0 : 1, effectSlotId: "comp", + tag: "miso.observationmap.v1", result: 0, structureEpoch: 1, + bindings: [{ trackIndex: 0, rack: 1, effectIndex: 1, effectSlotId: "comp", nativeEffectId: "miso.compressor", tapIds: [1] }], }; }, @@ -599,9 +598,11 @@ test("the browser engine sends an observation record only for the map of the lis selections: [{ trackId: "t", rack: "inserts", effectSlotId: "comp", tapId: 1, channels: "both" }], windowBlocks: 1, }); - assert.ok(rendered >= 3n, "the subscribe waited for the plan of the newest list to render"); - assert.equal(commands.length, 1, "one record batch, sent after the wait"); - assert.equal(commands[0][0].effectIndex, 0, "the record names the effect of the newest plan"); + assert.equal(commands.length, 1, "one record batch"); + assert.deepEqual(commands[0], [{ + kind: ABI_LAYOUT.constants.wireCommandKinds.find((row) => row.name === "observeSubscribe").value, + rack: 1, stripId: "t", effectId: "comp", tapId: 1, windowBlocks: 1, + }], "the record names the tap by its key, and no index of any plan"); await managed.close(); } finally { await engine.close(); diff --git a/sdk/test/observation-evals.mjs b/sdk/test/observation-evals.mjs index 6a665700e..daf3d5053 100644 --- a/sdk/test/observation-evals.mjs +++ b/sdk/test/observation-evals.mjs @@ -143,9 +143,9 @@ describe("issue 777 -- selected resident observations", () => { const liveControls = engine.liveControls(); const armed = await liveControls.submit( - liveControls.edit.track("t").effect("inserts", 0, "miso.compressor") + liveControls.edit.track("t").insert("comp", "miso.compressor") .observe("Gain Reduction", true, 2), - liveControls.edit.track("t").effect("inserts", 1, "miso.gate-expander") + liveControls.edit.track("t").insert("gate", "miso.gate-expander") .observe("Gain Reduction", true, 2), ); assert.equal(armed.ok, true); @@ -171,7 +171,7 @@ describe("issue 777 -- selected resident observations", () => { // The prior resident cell survives a re-arm, but is historical for that new arm. A re-arm of // an armed tap is one record: it restarts the window, and no withdrawal comes first. const rearmed = await liveControls.submit( - liveControls.edit.track("t").effect("inserts", 0, "miso.compressor") + liveControls.edit.track("t").insert("comp", "miso.compressor") .observe("Gain Reduction", true, 2), ); assert.equal(rearmed.ok, true); diff --git a/sdk/test/observation-subscription-evals.mjs b/sdk/test/observation-subscription-evals.mjs index 96842cef5..a3e36c908 100644 --- a/sdk/test/observation-subscription-evals.mjs +++ b/sdk/test/observation-subscription-evals.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { before, describe, test } from "node:test"; +import { ABI_LAYOUT } from "../src/generated/abi.ts"; import { CATALOG } from "../src/generated/catalog.ts"; import { MisoEngineAsset } from "../src/core/asset.ts"; import { MisoEngineError, MisoUsageError } from "../src/core/errors.ts"; @@ -151,7 +152,7 @@ describe("issue 783 -- managed resident observation subscriptions", () => { assert.deepEqual(first.readLatest().map((row) => row.status), ["pending", "pending"]); const liveControls = engine.liveControls(); await assert.rejects( - () => liveControls.submit(liveControls.edit.track("t").effect("inserts", 0, "miso.compressor") + () => liveControls.submit(liveControls.edit.track("t").insert("comp", "miso.compressor") .observe("Gain Reduction", false, 2)), /conflict/, ); @@ -237,7 +238,7 @@ describe("issue 783 -- managed resident observation subscriptions", () => { }); try { const liveControls = engine.liveControls(); - const edit = (on, window) => liveControls.edit.track("t").effect("inserts", 0, "miso.compressor") + const edit = (on, window) => liveControls.edit.track("t").insert("comp", "miso.compressor") .observe("Gain Reduction", on, window); assert.equal((await liveControls.submit(edit(true, 4))).ok, true); feed(engine, 0); engine.render(); @@ -489,6 +490,7 @@ describe("issue 1345 -- the browser engine waits for a render, within a bound", let rendered = 0n; let slotFull = false; let nextMalformed = false; + let nextRefusal = null; let sticky = false; const ack = (result, reason, admitted) => ({ tag: "miso.ack.v1", requestId: 1, result, reason, rejectedIndex: 0, admitted, @@ -505,6 +507,11 @@ describe("issue 1345 -- the browser engine waits for a render, within a bound", async command(request) { log.commands.push({ at: rendered, kinds: request.commands.map((command) => command.kind) }); if (nextMalformed) { nextMalformed = false; return ack(1, 1, 0); } + if (nextRefusal !== null) { + const { result, reason } = nextRefusal; + nextRefusal = null; + return ack(result, reason, 0); + } if (slotFull || sticky) return ack(6, 8, 0); slotFull = true; return ack(0, 0, request.commands.length); @@ -539,6 +546,7 @@ describe("issue 1345 -- the browser engine waits for a render, within a bound", log, render() { rendered += 1n; slotFull = false; }, refuseNextAsMalformed() { nextMalformed = true; }, + refuseNextWith(result, reason) { nextRefusal = { result, reason }; }, // Another writer takes the slot again after every render, so no render ever frees it. keepRefusing() { sticky = true; }, }; @@ -682,4 +690,30 @@ describe("issue 1345 -- the browser engine waits for a render, within a bound", await engine.close(); } }); + + // #1676: the cap on armed taps is its own reason, never backpressure. Red if the SDK treats it as + // flow control (it would wait for a render and send the same subscription again) or if the + // thrown error does not carry the cap as its diagnostic code. + test("a subscription past the cap is refused with observationLimit at once, never resent, and the error names it", async () => { + const rig = slotHost(); + const engine = await browserEngine(rig, { requestDeadlineMs: 2_000 }); + try { + const reason = ABI_LAYOUT.constants.commandReasons.find((row) => row.name === "observationLimit"); + assert.equal(reason?.value, 15, "observationLimit follows the retired 12-14"); + rig.refuseNextWith(7, reason.value); + const started = Date.now(); + setTimeout(() => rig.render(), 30); + await assert.rejects(() => engine.subscribeObservations(request), (error) => { + assert.ok(error instanceof MisoEngineError); + assert.equal(error.code, "unsupported"); + assert.equal(error.diagnosticCode, "observationLimit"); + assert.match(error.message, /unsubscribe another tap/); + return true; + }); + assert.ok(Date.now() - started < 500, "the cap refusal does not wait for a render"); + assert.equal(rig.log.commands.length, 1, "the one refused subscription, no resend"); + } finally { + await engine.close(); + } + }); }); diff --git a/sdk/test/structure-evals.mjs b/sdk/test/structure-evals.mjs index d3f13c093..31eefa1fb 100644 --- a/sdk/test/structure-evals.mjs +++ b/sdk/test/structure-evals.mjs @@ -5,7 +5,7 @@ * the taps a subscription holds can all change under a running engine. The SDK maps and reports; the * core decides. These evals send the structural frames the Rust codec wrote (`control-support.mjs`) * to a real core, on the headless engine and through the browser transport over the same core, and - * hold what the SDK names -- the strips of a meter frame, the row of a named effect control, the + * hold what the SDK names -- the strips of a meter frame, the effect an effect-row edit is held to, the * index an observation record carries, the end of a subscription -- to what the core now says. */ @@ -14,11 +14,12 @@ import { readFileSync } from "node:fs"; import { before, describe, test } from "node:test"; import { createBrowserLiveControls } from "../src/browser/live-controls.ts"; +import { observationEdit } from "../src/core/live-controls.ts"; import { MisoEngineAsset } from "../src/core/asset.ts"; import { decodeControlReply } from "../src/core/control-frame.ts"; import { ObservationSubscriptionOwner } from "../src/core/observation-subscriptions.ts"; import { decodeObservationEnded } from "../src/core/observation-ended.ts"; -import { MisoUsageError } from "../src/core/errors.ts"; +import { MisoControlRefusal } from "../src/core/errors.ts"; import { createOfflineEngine } from "../src/headless/engine.ts"; import { structuralFrame } from "./control-support.mjs"; import { engineHost, moduleBytes, ramp } from "./support.mjs"; @@ -167,13 +168,37 @@ describe("#1562b-K -- a meter frame names its own strips", () => { }); }); -describe("#1562b-K -- a named effect control follows the effect the core now lists", () => { +describe("#1678 -- the core holds an effect-row edit to the effect it was written for", () => { const named = (controls, name, value) => controls.edit.set({ owner: STRIP("t1"), section: { kind: "inserts", effect: "comp" }, control: name, lane: "both", }, value); + const refusalOf = async (controls, set) => { + const refusal = await controls.submit(set).then(() => undefined, (error) => error); + assert.ok(refusal instanceof MisoControlRefusal, "the core refused"); + return refusal; + }; + + test("the builders send the effect an edit was written for", async () => { + const engine = await boot(); + try { + const controls = engine.liveControls(); + const insert = controls.edit.track("t1").insert("comp", "miso.compressor").parameter("mix", 0.25); + assert.equal(insert.edits[0].address.expectedEffect, "miso.compressor"); + assert.equal(named(controls, "mix", 0.25).edits[0].address.expectedEffect, "miso.compressor", + "a row named by its section carries the effect the section runs"); + const numeric = controls.edit.set({ + owner: STRIP("t1"), section: { kind: "inserts", effect: "comp" }, control: 7, lane: "both", + }, 0.25); + assert.equal("expectedEffect" in numeric.edits[0].address, false, "a bare numeric address names no effect"); + assert.equal("expectedEffect" in controls.edit.track("t1").faderDb(-3).edits[0].address, false, + "a row outside an effect section names none"); + } finally { + engine.dispose(); + } + }); for (const path of ["headless", "browser"]) { - test(`${path}: a row named before an insert changed effect lands on the new effect's row`, async () => { + test(`${path}: an edit written for the old effect is refused by the core after a rebuild`, async () => { const engine = await boot(); try { run(engine, 0, 2); @@ -192,33 +217,34 @@ describe("#1562b-K -- a named effect control follows the effect the core now lis controls.queue.reset(engine.controlRevision()); run(engine, 2, 4); const list = await engine.controls(); - const clipMix = insertRow(list, "t1", "comp", "mix"); - assert.equal(clipMix.address.control, 3, "the soft clip's mix row"); - - // Built before the swap, from the old view: the number is the compressor's, and it is sent - // as the soft clip's. - const stale = named(controls, "mix", 0.5); - assert.equal((await controls.submit(stale)).ok, true); - assert.equal(insertRow(await engine.controls(), "t1", "comp", "mix").value, 0.5); - - // A row the new effect does not have is refused by the SDK, not sent to another row. - await assert.rejects( - () => controls.submit(named(controls, "threshold_db", -20)), - (error) => error instanceof MisoUsageError && /runs miso\.soft-clip now, which has no control 'threshold_db'/.test(error.message), - ); - - // An edit typed for the compressor is not sent to the soft clip. - const typed = controls.edit.track("t1").insert("comp", "miso.compressor").parameter("mix", 0.75); - await assert.rejects( - () => controls.submit(typed), - (error) => error instanceof MisoUsageError && /runs miso\.soft-clip now, and this edit was built for miso\.compressor/.test(error.message), - ); - assert.equal(insertRow(await engine.controls(), "t1", "comp", "mix").value, 0.5, "nothing was sent"); + assert.equal(insertRow(list, "t1", "comp", "mix").address.control, 3, "the soft clip's mix row"); + const untouched = insertRow(list, "t1", "comp", "mix").value; + const revision = engine.controlRevision(); + + // Built from the old view: row 7 of the compressor. The core refuses it with its own code. + for (const stale of [ + named(controls, "mix", 0.5), + named(controls, "threshold_db", -20), + controls.edit.track("t1").insert("comp", "miso.compressor").parameter("mix", 0.75), + ]) { + const refusal = await refusalOf(controls, stale); + assert.equal(refusal.code, "control.effect.changed"); + assert.equal(refusal.editIndex, 0); + } + assert.equal(insertRow(await engine.controls(), "t1", "comp", "mix").value, untouched, "nothing changed"); + assert.equal(engine.controlRevision(), revision, "nothing committed"); // The same insert typed for the effect it runs now goes through. const typedNow = controls.edit.track("t1").insert("comp", "miso.soft-clip").parameter("mix", 0.75); assert.equal((await controls.submit(typedNow)).ok, true); assert.equal(insertRow(await engine.controls(), "t1", "comp", "mix").value, 0.75); + + // A numeric address names no effect, so it lands on whatever runs there: as before. + const bare = controls.edit.set({ + owner: STRIP("t1"), section: { kind: "inserts", effect: "comp" }, control: 3, lane: "both", + }, 0.5); + assert.equal((await controls.submit(bare)).ok, true); + assert.equal(insertRow(await engine.controls(), "t1", "comp", "mix").value, 0.5); } finally { engine.dispose(); } @@ -429,16 +455,15 @@ describe("#1562b-K -- a managed observation subscription follows a rebuild", () }); }); -// Batch 12b review, M1. A rebuild's commit moves the control list at once, and the plan that renders -// keeps its own indices until the block that adopts the replacement. An observation record names a -// strip by the newest list and an effect by an index of the map, and the core admits it against the -// newest plan, so a record built in that window can arm another effect's tap and be acked for it. -// `swap_t3_multi_and_limiter` puts two effects with the same tap (`Gain Reduction`, ID 1) at each -// other's index: the record for `multi` built from the rendering plan's map names index 0, which is -// `limiter` in the newest plan. -describe("batch 12b review M1 -- an observation record waits for the map of its epoch", () => { +// #1677 (the successor of batch 12b review M1). A rebuild's commit moves the control list at once, +// and the plan that renders keeps its own positions until the block that adopts the replacement. An +// observation record names its tap by stable key (the strip, the effect and the tap) and the core +// resolves it on the newest committed plan, so a record sent in that window arms the tap it names +// and no client keeps an index space to get wrong. `swap_t3_multi_and_limiter` puts two effects with +// the same tap (`Gain Reduction`, ID 1) at each other's index: a record that named `multi` by the +// rendering plan's index 0 would arm `limiter`, which is index 0 in the newest plan. +describe("#1677 -- an observation record names its tap by stable key", () => { const armed = (engine, effectSlotId) => engine.readObservations([tap("t3", "inserts", effectSlotId)])[0].status; - const behind = (error) => error.code === "backpressure" && error.diagnosticCode === "sdk.observation.structure_behind"; /** An engine whose rebuild is committed and not yet adopted. */ async function committedNotAdopted() { @@ -448,117 +473,133 @@ describe("batch 12b review M1 -- an observation record waits for the map of its return engine; } - test("a subscribe in the window is refused, arms nothing, and arms the right tap once a render adopted the plan", async () => { - const engine = await committedNotAdopted(); - try { - await assert.rejects( - () => engine.subscribeObservations({ selections: [tap("t3", "inserts", "multi")], windowBlocks: 2 }), - behind, - "the headless engine renders only when its caller does: the refusal is typed and retryable", - ); - assert.equal(armed(engine, "multi"), "unarmed"); - assert.equal(armed(engine, "limiter"), "unarmed", "the other effect's tap is not armed in its place"); - run(engine, 4, 2); - const handle = await engine.subscribeObservations({ selections: [tap("t3", "inserts", "multi")], windowBlocks: 2 }); - run(engine, 6, 4); - await handle.pump(); - assert.equal(handle.readLatest()[0].status, "ready"); - assert.notEqual(armed(engine, "multi"), "unarmed", "multi's own tap is armed"); - assert.equal(armed(engine, "limiter"), "unarmed", "limiter's tap is not"); - await handle.close(); - } finally { - engine.dispose(); - } - }); + /** The same subscriptions through the headless engine and through the browser transport. */ + const transports = [ + ["headless", (engine) => engine], + ["browser", (engine) => { + const host = engineHost(engine); + let controls; + const owner = new ObservationSubscriptionOwner({ + observationMap: () => engine.observationMap(), + readObservations: (selections) => engine.readObservations(selections), + liveControls: async () => (controls ??= await createBrowserLiveControls(host)), + }); + return { + subscribeObservations: async (request) => (await owner.subscribe(request)).handle, + readObservations: (selections) => engine.readObservations(selections), + }; + }], + ]; - test("a close in the window is refused and disarms the right tap after the adoption", async () => { - const engine = await boot(); - try { - const handle = await engine.subscribeObservations({ selections: [tap("t3", "inserts", "multi")], windowBlocks: 2 }); - run(engine, 0, 4); - rebuild(engine, "swap_t3_multi_and_limiter"); - await assert.rejects(() => handle.close(), behind); - assert.notEqual(armed(engine, "multi"), "unarmed", "the refused close left the subscription as it was"); - run(engine, 4, 2); - await handle.close(); - run(engine, 6, 2); - assert.equal(armed(engine, "multi"), "unarmed", "multi is disarmed where it is now"); - assert.equal(armed(engine, "limiter"), "unarmed"); - } finally { - engine.dispose(); - } - }); + for (const [transport, open] of transports) { + test(`${transport}: a subscribe in the window is accepted and arms the named tap, not its neighbour`, async () => { + const engine = await committedNotAdopted(); + try { + const handle = await open(engine).subscribeObservations({ + selections: [tap("t3", "inserts", "multi")], + windowBlocks: 2, + }); + assert.equal(armed(engine, "limiter"), "unarmed", "the other effect's tap is not armed in its place"); + run(engine, 4, 6); + await handle.pump(); + assert.equal(handle.readLatest()[0].status, "ready"); + assert.notEqual(armed(engine, "multi"), "unarmed", "multi's own tap is armed"); + assert.equal(armed(engine, "limiter"), "unarmed", "limiter's tap is not"); + await handle.close(); + } finally { + engine.dispose(); + } + }); - test("an update in the window is refused and arms the right tap after the adoption", async () => { - const engine = await boot(); - try { - const handle = await engine.subscribeObservations({ selections: [tap("t3", "inserts", "limiter")], windowBlocks: 2 }); - run(engine, 0, 4); - rebuild(engine, "swap_t3_multi_and_limiter"); - await assert.rejects(() => handle.update({ selections: [tap("t3", "inserts", "multi")], windowBlocks: 2 }), behind); - run(engine, 4, 2); - await handle.update({ selections: [tap("t3", "inserts", "multi")], windowBlocks: 2 }); - run(engine, 6, 4); - await handle.pump(); - assert.equal(handle.readLatest()[0].status, "ready"); - assert.notEqual(armed(engine, "multi"), "unarmed"); - assert.equal(armed(engine, "limiter"), "unarmed", "limiter was released where it is now"); - await handle.close(); - } finally { - engine.dispose(); - } - }); + test(`${transport}: a close in the window disarms the named tap, not its neighbour`, async () => { + const engine = await boot(); + try { + const handle = await open(engine).subscribeObservations({ + selections: [tap("t3", "inserts", "multi")], + windowBlocks: 2, + }); + run(engine, 0, 4); + rebuild(engine, "swap_t3_multi_and_limiter"); + await handle.close(); + run(engine, 4, 4); + assert.equal(armed(engine, "multi"), "unarmed", "multi is disarmed where it is now"); + assert.equal(armed(engine, "limiter"), "unarmed"); + } finally { + engine.dispose(); + } + }); - /** An owner over the engine whose renders run on a clock of their own: a wait renders one block. */ - function ownerRenderingOnWait(engine, { rendered = true } = {}) { - let block = 20; - const waits = []; - const owner = new ObservationSubscriptionOwner({ - observationMap: () => engine.observationMap(), - readObservations: (selections) => engine.readObservations(selections), - liveControls: () => engine.liveControls(), - renderWait: { - boundMs: 1_000, - mark: async () => engine.status().renderedQuanta, - next: async () => { - waits.push(block); - if (rendered) run(engine, block, 1); - block += 1; - return rendered; - }, - }, + test(`${transport}: an update in the window moves the arm to the named tap`, async () => { + const engine = await boot(); + try { + const handle = await open(engine).subscribeObservations({ + selections: [tap("t3", "inserts", "limiter")], + windowBlocks: 2, + }); + run(engine, 0, 4); + rebuild(engine, "swap_t3_multi_and_limiter"); + await handle.update({ selections: [tap("t3", "inserts", "multi")], windowBlocks: 2 }); + run(engine, 4, 6); + await handle.pump(); + assert.equal(handle.readLatest()[0].status, "ready"); + assert.notEqual(armed(engine, "multi"), "unarmed"); + assert.equal(armed(engine, "limiter"), "unarmed", "limiter was released where it is now"); + await handle.close(); + } finally { + engine.dispose(); + } }); - return { owner, waits }; } - test("an engine that renders on its own clock waits for a render and sends the record of the adopted plan", async () => { - const engine = await committedNotAdopted(); - const { owner, waits } = ownerRenderingOnWait(engine); + test("a key the plan does not have is refused with its typed reason, and nothing changes", async () => { + const engine = await boot(); try { - const { handle } = await owner.subscribe({ selections: [tap("t3", "inserts", "multi")], windowBlocks: 2 }); - assert.ok(waits.length >= 1, "it waited for a render"); - assert.notEqual(armed(engine, "multi"), "unarmed", "the tap of multi is armed"); - assert.equal(armed(engine, "limiter"), "unarmed", "the tap of limiter is not"); - await handle.close(); - run(engine, 30, 2); - assert.equal(armed(engine, "multi"), "unarmed"); + run(engine, 0, 4); + const controls = engine.liveControls(); + const edits = controls.edit.track("t3"); + const known = edits.insert("multi", "miso.multiband-compressor").observe("Gain Reduction", true, 2); + const refusals = [ + ["unknownEffect", edits.insert("no-such-insert", "miso.compressor").observe("Gain Reduction", true, 2)], + ["unknownTrack", controls.edit.track("no-such-strip").insert("multi", "miso.multiband-compressor") + .observe("Gain Reduction", true, 2)], + ["unknownTap", observationEdit(true, { stripId: "t3", rack: "inserts", effectId: "multi", tapId: 99 }, 2)], + ["unknownRack", { ...known, rack: 0 }], + ]; + for (const [reasonName, bad] of refusals) { + // The valid record rides in front of the refused one: the batch is one transaction. + const report = await controls.submit(known, bad); + assert.equal(report.ok, false, reasonName); + assert.equal(report.reasonName, reasonName); + assert.equal(report.rejectedIndex, 1, `${reasonName} names the record that broke the rule`); + assert.equal(report.admitted, 0); + run(engine, 4, 3); + assert.equal(armed(engine, "multi"), "unarmed", `${reasonName}: the valid record in front was not armed`); + assert.equal(armed(engine, "limiter"), "unarmed"); + } + // The same key, alone, is admitted: the refusals changed no state. + assert.equal((await controls.submit(known)).ok, true); } finally { - owner.invalidate(true); engine.dispose(); } }); - test("a map that does not catch up within the wait is refused, and nothing was sent", async () => { - const engine = await committedNotAdopted(); - const { owner, waits } = ownerRenderingOnWait(engine, { rendered: false }); + test("a key is resolved on the newest plan: a tap the replacement no longer has is refused", async () => { + // `swap_comp_for_soft_clip` makes the insert `comp` of `t1` a soft clipper in the replacement. + // Between its commit and its adoption the rendering plan still runs the compressor, whose map + // lists the tap; the core refuses the key against the newest plan. + const engine = await boot(); try { - await assert.rejects(() => owner.subscribe({ selections: [tap("t3", "inserts", "multi")], windowBlocks: 2 }), behind); - assert.equal(waits.length, 1); - run(engine, 20, 4); - assert.equal(armed(engine, "multi"), "unarmed"); - assert.equal(armed(engine, "limiter"), "unarmed"); + run(engine, 0, 4); + rebuild(engine, "swap_comp_for_soft_clip"); + assert.equal(engine.observationMap().bindings.some((b) => b.trackId === "t1" && b.effectSlotId === "comp"), true); + const controls = engine.liveControls(); + const report = await controls.submit( + controls.edit.track("t1").insert("comp", "miso.compressor").observe("Gain Reduction", true, 2), + ); + assert.equal(report.ok, false); + assert.equal(report.reasonName, "unknownTap"); + assert.equal(report.admitted, 0); } finally { - owner.invalidate(true); engine.dispose(); } }); diff --git a/sdk/test/support.mjs b/sdk/test/support.mjs index 15a53942c..a19f430c2 100644 --- a/sdk/test/support.mjs +++ b/sdk/test/support.mjs @@ -186,12 +186,14 @@ export function engineHost(engine, { frames = [] } = {}) { const base = index * ABI_LAYOUT.commandRecord.bytes; view.setUint8(base + at("kind"), command.kind); view.setUint8(base + at("rack"), command.rack); - view.setUint8(base + at("channel"), command.channel); - view.setUint32(base + at("trackIndex"), command.trackIndex, true); - view.setUint32(base + at("effectIndex"), command.effectIndex, true); - view.setUint32(base + at("parameterId"), command.parameterId, true); - view.setUint32(base + at("smoothingSamples"), command.smoothingSamples, true); - command.values.forEach((v, slot) => view.setFloat32(base + at("values") + slot * 4, v, true)); + const strip = new TextEncoder().encode(command.stripId); + const effect = new TextEncoder().encode(command.effectId); + view.setUint8(base + at("stripIdBytes"), strip.length); + view.setUint8(base + at("effectIdBytes"), effect.length); + view.setUint32(base + at("tapId"), command.tapId, true); + view.setUint32(base + at("windowBlocks"), command.windowBlocks, true); + bytes.set(strip, base + at("stripId")); + bytes.set(effect, base + at("effectId")); }); const report = engine.submitCommands(bytes, value.commands.length); return { diff --git a/sdk/test/writer-evals.mjs b/sdk/test/writer-evals.mjs index 1f46a963d..959f948a3 100644 --- a/sdk/test/writer-evals.mjs +++ b/sdk/test/writer-evals.mjs @@ -17,7 +17,7 @@ import assert from "node:assert/strict"; import { before, describe, test } from "node:test"; import { MisoEngineAsset } from "../src/core/asset.ts"; -import { LiveControlWriter } from "../src/core/writer.ts"; +import { LiveControlWriter, encodeLaneEdits } from "../src/core/writer.ts"; import { CONTROL_TRANSACTION_HEADER_BYTES, controlEditBytes } from "../src/core/control-frame.ts"; import { MisoControlRefusal, MisoEngineError, MisoUsageError } from "../src/core/errors.ts"; import { ABI_LAYOUT } from "../src/generated/abi.ts"; @@ -130,7 +130,7 @@ async function observedEngine(count = 1) { /** The subscription (or its withdrawal) of compressor `index`'s tap, as the SDK's own builder makes it. */ function observeEdit(engine, index, armed, windowBlocks = 0) { return engine.liveControls().edit.track("t") - .effect("inserts", index, "miso.compressor") + .insert(`compressor${index}`, "miso.compressor") .observe("Gain Reduction", armed, windowBlocks); } @@ -386,9 +386,43 @@ describe("the writer contract -- coalescing and escalation", () => { assert.equal(writer.pending, 0); }); + test("an observation record carries its tap's stable key and nothing positional (#1677)", async () => { + const at = (name) => ABI_LAYOUT.commandRecord.fields.find((row) => row.name === name).offset; + const edit = (stripId, effectId, tapId = 1) => ({ + kind: "observeSubscribe", rack: 1, stripId, effectId, tapId, windowBlocks: 9, + }); + const bytes = encodeLaneEdits([edit("kick", "comp", 7), edit("snare", "gate")]); + assert.equal(bytes.byteLength, 2 * ABI_LAYOUT.commandRecord.bytes); + const view = new DataView(bytes.buffer); + const text = (offset, length) => new TextDecoder().decode(bytes.subarray(offset, offset + length)); + assert.equal(view.getUint8(at("stripIdBytes")), 4); + assert.equal(view.getUint8(at("effectIdBytes")), 4); + assert.equal(text(at("stripId"), 4), "kick"); + assert.equal(text(at("effectId"), 4), "comp"); + assert.equal(view.getUint32(at("tapId"), true), 7); + assert.equal(view.getUint32(at("windowBlocks"), true), 9); + // The field is zero padded after the ID: a byte of the previous record never shows through. + const second = ABI_LAYOUT.commandRecord.bytes; + assert.equal(text(second + at("stripId"), 5), "snare"); + assert.ok(bytes.subarray(second + at("stripId") + 5, second + at("stripId") + 128).every((byte) => byte === 0)); + // An ID the field cannot hold is refused before anything is staged. + assert.throws(() => encodeLaneEdits([edit("x".repeat(128), "comp")]), MisoUsageError); + assert.throws(() => encodeLaneEdits([edit("kick", "é".repeat(64))]), MisoUsageError); + assert.equal(encodeLaneEdits([edit("x".repeat(127), "y".repeat(127))]).byteLength, ABI_LAYOUT.commandRecord.bytes); + // Two taps that differ in the strip, the effect or the tap do not coalesce into one record. + const sent = []; + const writer = new LiveControlWriter({ + submit: (records, count) => { sent.push(count); return { ok: true, result: 0, admitted: count }; }, + }); + for (const staged of [edit("a", "bc"), edit("ab", "c"), edit("a", "bc", 2), edit("a", "bc")]) writer.stage(staged); + assert.equal(writer.stats.coalesced, 1, "only the repeat of the first tap coalesces"); + await writer.flush(); + assert.deepEqual(sent, [3]); + }); + test("one writer sends one kind of edit", () => { const control = new LiveControlWriter({ queue: { submit: () => assert.fail("not reached") } }); - assert.throws(() => control.stage({ kind: "observeSubscribe", trackIndex: 0, rack: 1, channel: 255, values: [0, 0, 0, 0] }), MisoUsageError); + assert.throws(() => control.stage({ kind: "observeSubscribe", rack: 1, stripId: "t", effectId: "comp", tapId: 1, windowBlocks: 0 }), MisoUsageError); const records = new LiveControlWriter({ submit: () => assert.fail("not reached") }); assert.throws(() => records.stage(faderEdit(0, -1)), MisoUsageError); assert.throws(() => control.stage({ ...faderEdit(0, -1), rampSamples: 1.5 }), MisoUsageError); diff --git a/tools/audit/src/protocol.rs b/tools/audit/src/protocol.rs index 522c14767..75eada3be 100644 --- a/tools/audit/src/protocol.rs +++ b/tools/audit/src/protocol.rs @@ -182,7 +182,6 @@ fn prepare_corpus() -> Corpus { expected_revision: revision, payload: CommandPayload::TransportSet(protocol::TransportSetRequest { state: protocol::TransportState::Stopped, - position: None, }), }); corpus.push_command(TypedCommandFrame { diff --git a/tools/parameter-metadata/src/abi_layout.rs b/tools/parameter-metadata/src/abi_layout.rs index 0660aa895..c52236167 100644 --- a/tools/parameter-metadata/src/abi_layout.rs +++ b/tools/parameter-metadata/src/abi_layout.rs @@ -55,12 +55,13 @@ use host_web::{ BUFFER_DIAGNOSTIC, BUFFER_METER_FRAME, BUFFER_OUTPUT_PCM, BUFFER_SOURCE_ID, BUFFER_SOURCE_PCM, COMMAND_OBSERVE_SUBSCRIBE, COMMAND_OBSERVE_UNSUBSCRIBE, COMMAND_REASON_BACKPRESSURE, COMMAND_REASON_DOMAIN, COMMAND_REASON_MALFORMED, COMMAND_REASON_NONE, - COMMAND_REASON_OBSERVATION_UNBOUND, COMMAND_REASON_UNKNOWN_EFFECT, - COMMAND_REASON_UNKNOWN_PARAMETER, COMMAND_REASON_UNKNOWN_RACK, COMMAND_REASON_UNKNOWN_TAP, - COMMAND_REASON_UNKNOWN_TRACK, COMMAND_REASON_UNSUPPORTED_KIND, COMMAND_REASON_WRONG_STATE, - COMMAND_RECORD_BYTES, COMMAND_REPORT_BYTES, DEFAULT_MAXIMUM_MEMORY_BYTES, DEFAULT_METER_BLOCKS, - DIAGNOSTIC_BYTES, LIVE_RESPONSE_CAPTURE_BYTES, LIVE_RESPONSE_MAXIMUM_ID_BYTES, - LIVE_RESPONSE_MAXIMUM_OWNERS, LIVE_RESPONSE_MAXIMUM_POINTS, LIVE_RESPONSE_MAXIMUM_SECTIONS, + COMMAND_REASON_OBSERVATION_LIMIT, COMMAND_REASON_OBSERVATION_UNBOUND, + COMMAND_REASON_UNKNOWN_EFFECT, COMMAND_REASON_UNKNOWN_PARAMETER, COMMAND_REASON_UNKNOWN_RACK, + COMMAND_REASON_UNKNOWN_TAP, COMMAND_REASON_UNKNOWN_TRACK, COMMAND_REASON_UNSUPPORTED_KIND, + COMMAND_REASON_WRONG_STATE, COMMAND_RECORD_BYTES, COMMAND_REPORT_BYTES, + DEFAULT_MAXIMUM_MEMORY_BYTES, DEFAULT_METER_BLOCKS, DIAGNOSTIC_BYTES, + LIVE_RESPONSE_CAPTURE_BYTES, LIVE_RESPONSE_MAXIMUM_ID_BYTES, LIVE_RESPONSE_MAXIMUM_OWNERS, + LIVE_RESPONSE_MAXIMUM_POINTS, LIVE_RESPONSE_MAXIMUM_SECTIONS, LIVE_RESPONSE_MEANING_EQ_FILTER_SUBTOTAL, LIVE_RESPONSE_MODE_TARGET, LIVE_RESPONSE_OWNER_BYTES, LIVE_RESPONSE_RACK_CONSOLE, LIVE_RESPONSE_RACK_INPUT_FILTERS, LIVE_RESPONSE_RACK_INSERTS, LIVE_RESPONSE_REQUEST_BYTES, LIVE_RESPONSE_RESULT_BYTES, LIVE_RESPONSE_SECTION_BYTES, @@ -704,19 +705,17 @@ fn observation_result_fields() -> [Field; 19] { /// is no Rust type to take `offset_of!` of — so the rows are transcribed from that function and /// pinned against it by `tests/abi_layout.rs`, which encodes a record at these offsets and /// requires the engine to decode exactly the values written. -fn command_record_fields() -> [Field; 11] { +fn command_record_fields() -> [Field; 9] { [ ("kind", 0, "u8"), ("rack", 1, "u8"), - ("channel", 2, "u8"), - ("reserved0", 3, "u8"), - ("trackIndex", 4, "u32"), - ("effectIndex", 8, "u32"), - ("parameterId", 12, "u32"), - ("smoothingSamples", 16, "u32"), - ("reserved1", 20, "u32"), - ("values", 24, "f32[4]"), - ("reserved2", 40, "u8[8]"), + ("stripIdBytes", 2, "u8"), + ("effectIdBytes", 3, "u8"), + ("tapId", 4, "u32"), + ("windowBlocks", 8, "u32"), + ("reserved0", 12, "u32"), + ("stripId", 16, "u8[128]"), + ("effectId", 144, "u8[128]"), ] } @@ -1344,6 +1343,8 @@ pub fn render() -> String { (COMMAND_REASON_WRONG_STATE, "wrongState"), (COMMAND_REASON_UNKNOWN_TAP, "unknownTap"), (COMMAND_REASON_OBSERVATION_UNBOUND, "observationUnbound"), + // 12-14 are retired with the value kinds (#1565A) and never reallocated; #1676 added 15. + (COMMAND_REASON_OBSERVATION_LIMIT, "observationLimit"), ]; let mut out = String::with_capacity(1 << 14); diff --git a/tools/parameter-metadata/src/lib.rs b/tools/parameter-metadata/src/lib.rs index 1742c0770..e86e4e426 100644 --- a/tools/parameter-metadata/src/lib.rs +++ b/tools/parameter-metadata/src/lib.rs @@ -110,10 +110,10 @@ use effect_contract::{ use host_web::{ ABI_VERSION, COMMAND_OBSERVE_SUBSCRIBE, COMMAND_OBSERVE_UNSUBSCRIBE, COMMAND_REASON_BACKPRESSURE, COMMAND_REASON_DOMAIN, COMMAND_REASON_MALFORMED, - COMMAND_REASON_NONE, COMMAND_REASON_OBSERVATION_UNBOUND, COMMAND_REASON_UNKNOWN_EFFECT, - COMMAND_REASON_UNKNOWN_PARAMETER, COMMAND_REASON_UNKNOWN_RACK, COMMAND_REASON_UNKNOWN_TAP, - COMMAND_REASON_UNKNOWN_TRACK, COMMAND_REASON_UNSUPPORTED_KIND, COMMAND_REASON_WRONG_STATE, - COMMAND_RECORD_BYTES, MAXIMUM_COMMAND_RECORDS, + COMMAND_REASON_NONE, COMMAND_REASON_OBSERVATION_LIMIT, COMMAND_REASON_OBSERVATION_UNBOUND, + COMMAND_REASON_UNKNOWN_EFFECT, COMMAND_REASON_UNKNOWN_PARAMETER, COMMAND_REASON_UNKNOWN_RACK, + COMMAND_REASON_UNKNOWN_TAP, COMMAND_REASON_UNKNOWN_TRACK, COMMAND_REASON_UNSUPPORTED_KIND, + COMMAND_REASON_WRONG_STATE, COMMAND_RECORD_BYTES, MAXIMUM_COMMAND_RECORDS, }; /// The emitted file name, shipped beside the Wasm artifact. @@ -199,6 +199,8 @@ pub fn render() -> String { // the host JS bound, the `.d.ts` enum and the schema gate's list to one another. (COMMAND_REASON_UNKNOWN_TAP, "unknownTap"), (COMMAND_REASON_OBSERVATION_UNBOUND, "observationUnbound"), + // 12-14 are retired with the value kinds (#1565A) and never reallocated; #1676 added 15. + (COMMAND_REASON_OBSERVATION_LIMIT, "observationLimit"), ]; for (index, (value, name)) in reasons.iter().enumerate() { append!( @@ -1084,7 +1086,6 @@ const fn mapping_name(mapping: ParameterMapping) -> &'static str { const fn automation_rate_name(rate: AutomationRate) -> &'static str { match rate { - AutomationRate::Sample => "sample", AutomationRate::Block => "block", AutomationRate::None => "none", } diff --git a/tools/parameter-metadata/tests/abi_layout.rs b/tools/parameter-metadata/tests/abi_layout.rs index 612907b12..9085fb78e 100644 --- a/tools/parameter-metadata/tests/abi_layout.rs +++ b/tools/parameter-metadata/tests/abi_layout.rs @@ -127,13 +127,13 @@ fn scalar_after(document: &str, after: &str, name: &str) -> u64 { /// The emitted command-record offsets decode, on the live engine, to exactly the values written. /// /// The record is an `observeSubscribe` (kind 7), the one kind with a field at every address the -/// document publishes that the engine reads: `kind`, `rack`, `channel`, `trackIndex`, -/// `effectIndex`, `parameterId` (the tap ID) and `smoothingSamples`. The fixture is the observed -/// compressor insert, whose tap 1 is resident. +/// document publishes that the engine reads: `kind`, `rack`, the two ID lengths and fields, +/// `tapId` and `windowBlocks`. The fixture is the observed compressor insert `comp` of strip `t0`, +/// whose tap 1 is resident. /// -/// Red mutation: move any row in `command_record_fields` -- say `parameterId` to 16 -- and the -/// engine reads the smoothing word as the tap ID (or the tap ID as zero); the acknowledgement stops -/// being `RESULT_OK` and becomes `UNKNOWN_TAP`. +/// Red mutation: move any row in `command_record_fields` -- say `tapId` to 8 -- and the engine reads +/// the window word as the tap ID (or the tap ID as zero); the acknowledgement stops being +/// `RESULT_OK` and becomes `UNKNOWN_TAP`. #[test] fn the_emitted_command_record_offsets_are_the_engine_s_own_decode_rule() { let document = render(); @@ -161,11 +161,12 @@ fn the_emitted_command_record_offsets_are_the_engine_s_own_decode_rule() { let kind = field_offset(&document, "commandRecord", "kind"); let rack = field_offset(&document, "commandRecord", "rack"); - let channel = field_offset(&document, "commandRecord", "channel"); - let track_index = field_offset(&document, "commandRecord", "trackIndex"); - let effect_index = field_offset(&document, "commandRecord", "effectIndex"); - let parameter_id = field_offset(&document, "commandRecord", "parameterId"); - let smoothing = field_offset(&document, "commandRecord", "smoothingSamples"); + let strip_bytes = field_offset(&document, "commandRecord", "stripIdBytes"); + let effect_bytes = field_offset(&document, "commandRecord", "effectIdBytes"); + let tap_id = field_offset(&document, "commandRecord", "tapId"); + let window = field_offset(&document, "commandRecord", "windowBlocks"); + let strip_id = field_offset(&document, "commandRecord", "stripId"); + let effect_id = field_offset(&document, "commandRecord", "effectId"); let staging = host .command_staging_mut() @@ -174,12 +175,13 @@ fn the_emitted_command_record_offsets_are_the_engine_s_own_decode_rule() { staging[kind] = u8::try_from(COMMAND_OBSERVE_SUBSCRIBE).expect("kind is a byte"); // Rack 1 is the inserts, where the fixture put its compressor. staging[rack] = 1; - staging[channel] = 255; - staging[track_index..track_index + 4].copy_from_slice(&0_u32.to_le_bytes()); - staging[effect_index..effect_index + 4].copy_from_slice(&0_u32.to_le_bytes()); + staging[strip_bytes] = 2; + staging[effect_bytes] = 4; + staging[strip_id..strip_id + 2].copy_from_slice(b"t0"); + staging[effect_id..effect_id + 4].copy_from_slice(b"comp"); // Tap 1 is the compressor's resident gain-reduction tap. - staging[parameter_id..parameter_id + 4].copy_from_slice(&1_u32.to_le_bytes()); - staging[smoothing..smoothing + 4].copy_from_slice(&2_u32.to_le_bytes()); + staging[tap_id..tap_id + 4].copy_from_slice(&1_u32.to_le_bytes()); + staging[window..window + 4].copy_from_slice(&2_u32.to_le_bytes()); let result = host.submit_commands(1); let report = *host.command_report(); diff --git a/tools/parameter-metadata/tests/control_command_heap.rs b/tools/parameter-metadata/tests/control_command_heap.rs index da0c055be..88fa32468 100644 --- a/tools/parameter-metadata/tests/control_command_heap.rs +++ b/tools/parameter-metadata/tests/control_command_heap.rs @@ -78,6 +78,7 @@ fn nine_edits(flip: bool) -> Vec { let mut edits: Vec = EQ_PARAMETERS .iter() .map(|&(control, value)| SessionEdit::SetControl { + expected_effect: None, address: ControlAddress { owner: strip(), section: Section::Console(id("eq-simd1")), @@ -94,6 +95,7 @@ fn nine_edits(flip: bool) -> Vec { .collect(); for (control, value) in [(HPF_HZ, 80.0_f32), (LPF_HZ, 12_000.0)] { edits.push(SessionEdit::SetControl { + expected_effect: None, address: ControlAddress { owner: strip(), section: Section::Builtins, diff --git a/tools/parameter-metadata/tests/round_trip.rs b/tools/parameter-metadata/tests/round_trip.rs index 2bc15f53c..287e66b29 100644 --- a/tools/parameter-metadata/tests/round_trip.rs +++ b/tools/parameter-metadata/tests/round_trip.rs @@ -289,27 +289,22 @@ fn session_with_every_effect(effects: &[&str]) -> String { ) } -#[allow(clippy::too_many_arguments)] -fn stage( - host: &mut AudioWorkletEngineHost, - kind: u32, - rack: u8, - channel: u8, - effect_index: u32, - parameter_id: u32, - value: f32, -) { +/// Stage one observation record that names insert `e{effect_index}` of track `t` and the tap +/// `tap_id` (#1677): the record carries the stable key, not a position. +fn stage(host: &mut AudioWorkletEngineHost, kind: u32, rack: u8, effect_index: u32, tap_id: u32) { let staging = host .command_staging_mut() .expect("prepared command staging"); let record = &mut staging[..COMMAND_RECORD_BYTES as usize]; record.fill(0); + let effect = format!("e{effect_index}"); record[0] = u8::try_from(kind).expect("frozen kind"); record[1] = rack; - record[2] = channel; - record[8..12].copy_from_slice(&effect_index.to_le_bytes()); - record[12..16].copy_from_slice(¶meter_id.to_le_bytes()); - record[24..28].copy_from_slice(&value.to_le_bytes()); + record[2] = 1; + record[3] = u8::try_from(effect.len()).expect("effect ID length"); + record[4..8].copy_from_slice(&tap_id.to_le_bytes()); + record[16] = b't'; + record[144..144 + effect.len()].copy_from_slice(effect.as_bytes()); } /// Issue #137 E7, rebuilt for the control plane (#1565A): every ID the metadata declares for an @@ -440,7 +435,7 @@ fn every_metadata_observation_tap_resolves_through_a_command_acknowledgement() { let index = u32::try_from(effect_index).expect("effect index"); for tap in descriptor.observations { for kind in [COMMAND_OBSERVE_SUBSCRIBE, COMMAND_OBSERVE_UNSUBSCRIBE] { - stage(&mut host, kind, 1, 255, index, tap.id.0, 0.0); + stage(&mut host, kind, 1, index, tap.id.0); let result = host.submit_commands(1); let reason = host.command_report().reason; // Every launch tap is resident, so every one of them binds. The rule the test @@ -470,7 +465,7 @@ fn every_metadata_observation_tap_resolves_through_a_command_acknowledgement() { } // The negative case: a tap id no effect declares does not resolve, and says so as a *tap*. - stage(&mut host, COMMAND_OBSERVE_SUBSCRIBE, 1, 255, 0, 4_242, 0.0); + stage(&mut host, COMMAND_OBSERVE_SUBSCRIBE, 1, 0, 4_242); assert_eq!(host.submit_commands(1), host_web::RESULT_INVALID_ARGUMENT); assert_eq!(host.command_report().reason, COMMAND_REASON_UNKNOWN_TAP); } diff --git a/tools/realtime-policy/src/policy.rs b/tools/realtime-policy/src/policy.rs index 0b0c372f8..1e6e6a4a9 100644 --- a/tools/realtime-policy/src/policy.rs +++ b/tools/realtime-policy/src/policy.rs @@ -216,10 +216,15 @@ impl Policy { // #1443's seven (three in `crates/source`, four in `spectrum.rs`) and the spectrum // service's two (`spectrum_service.rs`: the prepared captures' one-shot methods and the // service's arm, read, cancel and select; #1591 fold) and #1609's one (`spectrum.rs`: - // the one-shot render functions and the held-window publication). A change that adds - // a marker raises them in the same change. - marked_files_floor: 28, - marked_regions_floor: 129, + // the one-shot render functions and the held-window publication) and #1637's three + // (`crates/automation`: `value_at`, the event generator in `events.rs`, and the render + // reads of `CellProgram` and `Program::cell` in `program.rs`) and #1638's three + // (`crates/builtins`: the fader operation sink, and the bank's and the scalar track's + // `process_with`) and #1639's two (`crates/graph`: the jump-length cell's word decode + // and its block-entry read). The floors had fallen behind the tree before #1637; they are set + // to its counts again. A change that adds a marker raises them in the same change. + marked_files_floor: 32, + marked_regions_floor: 149, render_thread_exports: table::parse(RENDER_THREAD_EXPORTS) .unwrap_or_else(|error| panic!("render-thread-exports.json: {error}")), unwrapped_exports: UNWRAPPED_EXPORTS diff --git a/tools/wasm-gate-corpus/Cargo.toml b/tools/wasm-gate-corpus/Cargo.toml index 43d9e85e8..b8f851d2a 100644 --- a/tools/wasm-gate-corpus/Cargo.toml +++ b/tools/wasm-gate-corpus/Cargo.toml @@ -14,6 +14,7 @@ publish.workspace = true name = "wasm_gate_corpus" [dependencies] +automation.workspace = true builtins.workspace = true compressor.workspace = true delay.workspace = true diff --git a/tools/wasm-gate-corpus/src/lib.rs b/tools/wasm-gate-corpus/src/lib.rs index a17304f0e..ac0a9dd1b 100644 --- a/tools/wasm-gate-corpus/src/lib.rs +++ b/tools/wasm-gate-corpus/src/lib.rs @@ -40,9 +40,12 @@ //! pin keeps its index, while the delegated blocks after it -- whose pins live in their own crates //! and are indexed there -- move up by one. The original layout ends with the compressor family of issue #88. //! Issue #213 appends two builtin cases after that family, preserving the original eight-case -//! builtin block and every later index. `tests/g5_native_corpus.rs` checks both the preserved -//! layout and the two appended cases. +//! builtin block and every later index. Issue #1637 appends the scalar [`automation::corpus`] +//! block after those two builtin cases, the last block, so no earlier index moves. +//! `tools/wasm-gates/tests/g5_native_corpus.rs` checks the preserved layout, the two builtin cases +//! after it and the automation block at the end. +use automation::corpus as automation_corpus; use builtins::corpus as builtins_corpus; use compressor::corpus as compressor_corpus; use delay::corpus as delay_corpus; @@ -235,6 +238,15 @@ pub const LIMITER_CASE_COUNT: usize = limiter_corpus::CASE_COUNT; /// live in that crate. pub const COMPRESSOR_CASE_COUNT: usize = compressor_corpus::CASE_COUNT; +/// Cases delegated to [`automation::corpus`] (issue #1637), replayed under wasm. +/// +/// The stored-automation event generator over fixed curve sets and node-time mappings: scalar +/// `f64` through `math::pow`, rounded once to `f32`, so these cases have no lane type and are run +/// once, like the math and delay cases. They are appended after the builtins tail, so no earlier +/// index moves. What they carry across the target boundary is the evaluation law and the grid +/// arithmetic of the events; their pins live in that crate. +pub const AUTOMATION_CASE_COUNT: usize = automation_corpus::CASE_COUNT; + /// Total cases the guest exports. pub const CASE_COUNT: usize = LANE_CASE_COUNT + MATH_CASE_COUNT @@ -247,7 +259,8 @@ pub const CASE_COUNT: usize = LANE_CASE_COUNT + GATE_EXPANDER_CASE_COUNT + BUILTINS_CASE_COUNT + LIMITER_CASE_COUNT - + COMPRESSOR_CASE_COUNT; + + COMPRESSOR_CASE_COUNT + + AUTOMATION_CASE_COUNT; /// The block kernels the corpus drives, in pin order. const KERNELS: [Kernel; 13] = [ @@ -460,6 +473,8 @@ enum Case { Limiter(usize), /// One case of the `compressor` E4 corpus. Compressor(usize), + /// One case of the `automation` event corpus. + Automation(usize), } /// Decodes a case index. This order is part of the pin. @@ -532,15 +547,20 @@ fn case_of(index: usize) -> Case { if index < COMPRESSOR_CASE_COUNT { return Case::Compressor(index); } - Case::Builtins(BUILTINS_ORIGINAL_CASE_COUNT + index - COMPRESSOR_CASE_COUNT) + let index = index - COMPRESSOR_CASE_COUNT; + let builtins_tail = BUILTINS_CASE_COUNT - BUILTINS_ORIGINAL_CASE_COUNT; + if index < builtins_tail { + return Case::Builtins(BUILTINS_ORIGINAL_CASE_COUNT + index); + } + Case::Automation(index - builtins_tail) } /// `true` when the case has a lane instantiation, so its digest must be identical at all three /// widths and is compared at each of them. /// -/// The math and delay cases are excluded: the math functions are scalar `f64`/`f32` with no lane -/// instantiation, and the delay is a `W = 1` effect, so both are run once and their digests cannot -/// depend on a width. +/// The math, delay and automation cases are excluded: the math functions are scalar `f64`/`f32` +/// with no lane instantiation, the delay is a `W = 1` effect, and the automation events are scalar +/// `f64`, so all three are run once and their digests cannot depend on a width. /// /// Everything else — this crate's kernels, the effect-runtime helpers and the soft-clip block — is /// one generic body instantiated per width, so all three widths must agree. @@ -550,7 +570,10 @@ fn case_of(index: usize) -> Case { /// Panics if `index >= CASE_COUNT`. #[must_use] pub fn is_width_dependent(index: usize) -> bool { - !matches!(case_of(index), Case::Math(_) | Case::Delay(_)) + !matches!( + case_of(index), + Case::Math(_) | Case::Delay(_) | Case::Automation(_) + ) } /// `true` when the case is one of this crate's own kernel or element-wise cases, whose per-lane @@ -604,6 +627,7 @@ pub fn case_name(index: usize) -> String { Case::Compressor(case) => { format!("effect/compressor/{}", compressor_corpus::CASE_NAMES[case]) } + Case::Automation(case) => automation_corpus::CASE_NAMES[case].to_string(), } } @@ -1132,6 +1156,7 @@ pub fn expected_digest(index: usize) -> [u8; 32] { Case::Builtins(case) => builtins_corpus::BUILTINS_DIGESTS[case], Case::Limiter(case) => limiter_corpus::D90_DIGESTS[case], Case::Compressor(case) => compressor_corpus::C1_DIGESTS[case], + Case::Automation(case) => automation_corpus::G5_DIGESTS[case], } } @@ -1160,6 +1185,7 @@ pub fn digest_case(index: usize, width: usize) -> [u8; 32] { Case::Builtins(case) => at_width!(width, |L| digest_builtins::(case)), Case::Limiter(case) => at_width!(width, |L| digest_limiter::(case)), Case::Compressor(case) => at_width!(width, |L| digest_compressor::(case)), + Case::Automation(case) => digest_words(automation_corpus::run_case(case)), } } @@ -1243,7 +1269,8 @@ fn lane_values(index: usize, width: usize, fused: bool) -> [[f32; FRAMES]; LANES | Case::GateExpander(_) | Case::Builtins(_) | Case::Limiter(_) - | Case::Compressor(_) => { + | Case::Compressor(_) + | Case::Automation(_) => { panic!("case {index} is not a lane-kernel case") } } diff --git a/tools/wasm-gates/Cargo.toml b/tools/wasm-gates/Cargo.toml index d8c0b42c5..8462a066a 100644 --- a/tools/wasm-gates/Cargo.toml +++ b/tools/wasm-gates/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" # `scripts/check-bench-policy.sh` fails if a second copy of a harness piece reappears under `tools/`, # so this edge is what keeps this crate from growing one. bench-support.workspace = true +automation.workspace = true builtins.workspace = true compressor.workspace = true effect-runtime.workspace = true diff --git a/tools/wasm-gates/tests/g5_native_corpus.rs b/tools/wasm-gates/tests/g5_native_corpus.rs index 9db822c94..f92cbe5dc 100644 --- a/tools/wasm-gates/tests/g5_native_corpus.rs +++ b/tools/wasm-gates/tests/g5_native_corpus.rs @@ -373,9 +373,9 @@ fn g5_delegated_cases_use_the_owning_crates_pins() { ); let compressor_base = limiter_base + corpus::LIMITER_CASE_COUNT; assert_eq!( - compressor_base + corpus::COMPRESSOR_CASE_COUNT + 2, + compressor_base + corpus::COMPRESSOR_CASE_COUNT + 2 + corpus::AUTOMATION_CASE_COUNT, corpus::CASE_COUNT, - "exactly two new builtin cases follow the original final compressor case" + "two builtin cases, then the automation block, follow the original final compressor case" ); assert_eq!(corpus::BUILTINS_ORIGINAL_CASE_COUNT, 8); assert_eq!(corpus::BUILTINS_CASE_COUNT, 10); @@ -391,6 +391,27 @@ fn g5_delegated_cases_use_the_owning_crates_pins() { ); assert!(corpus::is_width_dependent(index)); } + assert_eq!( + corpus::AUTOMATION_CASE_COUNT, + automation::corpus::CASE_COUNT, + "the automation block must cover every case that crate pins" + ); + let automation_base = compressor_base + corpus::COMPRESSOR_CASE_COUNT + 2; + for case in 0..corpus::AUTOMATION_CASE_COUNT { + assert_eq!( + corpus::expected_digest(automation_base + case), + automation::corpus::G5_DIGESTS[case], + "automation case {case} must be pinned by automation, not by this crate" + ); + assert!( + !corpus::is_width_dependent(automation_base + case), + "an automation case is scalar f64 and is run once" + ); + assert_eq!( + corpus::case_name(automation_base + case), + automation::corpus::CASE_NAMES[case] + ); + } for case in 0..corpus::COMPRESSOR_CASE_COUNT { assert_eq!( corpus::expected_digest(compressor_base + case), diff --git a/tools/wasm-gates/tests/g6_full_corpus_ftz.rs b/tools/wasm-gates/tests/g6_full_corpus_ftz.rs index 7dfba58fe..1f93eaa41 100644 --- a/tools/wasm-gates/tests/g6_full_corpus_ftz.rs +++ b/tools/wasm-gates/tests/g6_full_corpus_ftz.rs @@ -194,11 +194,14 @@ mod pinned { fn g6_the_guard_is_an_identity_for_a_caller_who_never_set_ftz() { let saved = read_word(); let clear = saved & !FLUSH_BITS; - let expected_rows = - (corpus::CASE_COUNT - corpus::MATH_CASE_COUNT - corpus::DELAY_CASE_COUNT) - * corpus::WIDTHS - + corpus::MATH_CASE_COUNT - + corpus::DELAY_CASE_COUNT; + let expected_rows = (corpus::CASE_COUNT + - corpus::MATH_CASE_COUNT + - corpus::DELAY_CASE_COUNT + - corpus::AUTOMATION_CASE_COUNT) + * corpus::WIDTHS + + corpus::MATH_CASE_COUNT + + corpus::DELAY_CASE_COUNT + + corpus::AUTOMATION_CASE_COUNT; let without = render_with_word(clear); let with = {