From e9dd126e3c7f51b6891d0a14beb7568eb3309634 Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 18:35:26 +0000 Subject: [PATCH 01/10] Admit observation bindings by the exact encoded bytes of the ended event (#1685) Co-Authored-By: Claude Opus 5.5 --- ...-exact-encoded-bytes-of-the-ended-event.md | 68 +++ Cargo.lock | 1 + crates/control-plane/Cargo.toml | 4 + crates/control-plane/src/compile.rs | 257 +++++++---- crates/control-plane/src/control.rs | 171 +++++--- .../tests/observation_bindings.rs | 400 ++++++++++++------ docs/CONTROL_PROTOCOL_SEMANTICS.md | 2 +- hosts/host-web/src/lib.rs | 36 +- hosts/host-web/src/tests.rs | 164 +++++-- .../miso-engine-v1-audio-worklet-host.d.ts | 8 +- scripts/test-web-audioworklet.mjs | 4 +- sdk/src/browser/shipped-host.d.ts | 8 +- sdk/src/core/observation-subscriptions.ts | 8 +- sdk/test/observation-subscription-evals.mjs | 13 +- 14 files changed, 815 insertions(+), 329 deletions(-) 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 index 2c56182e8..b5198507c 100644 --- 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 @@ -98,3 +98,71 @@ measured with the real IDs of the armed taps, not with the longest IDs the gramm - #1676 and #1677 (the cap, the stable key; batch 13b). - #1584 D6a (one event per commit). + +## Decision record: #1685 (batch 14a) + +**What shipped.** + +- **D1.** `SessionState` keeps `observation_armed_bytes` (the armed keys' encoded bytes, pushes not + yet folded included) and an `ObservationWire` (the frame with no key, a key's fixed part, the + control frame limit). `admit_and_push_observation_batch` judges the batch by + `base + armed bytes after every removal and arm <= frame`; the arm that crosses is + `ObservationRefusal::Limit(ObservationLimit { index, event_bytes, frame_bytes })` (bytes, not a + count), with nothing queued and no state moved. The naming and room checks are unchanged. + `observation_cap()` is removed; `observation_ended_bytes()` and `observation_frame_bytes()` + replace it. +- **D2.** A key's size is `key_fixed + pad8(strip_len) + pad8(effect_len)` (the BTLV value + alignment). `observation_wire` measures `base` and `key_fixed` with the real codec once per + session compile and checks the model against the codec for padded and longest IDs; a mismatch + fails the compile. Each observation slot stores its key's bytes as a `u16` (in the slot's + padding: `ObservationSlot` stays 16 bytes), set when the plan's slots are built, so admission + reads a slot field and allocates nothing. +- **D3.** Unchanged: one event per commit, room reserved at commit; a rebuild recomputes the armed + bytes from the kept bindings. +- **D4.** The ended-event heap and the binding rows charge, as ceilings, the peak over the key count + `n <= min(reserve slots, room / shortest key)` of `n` struct sizes plus the ID text, where the + text is `min(n * 254, room - n * key_fixed)` (an ID's text is at most its padded encoded length, + and every admitted key fits one frame together). The table no longer charges text per spare + capacity entry. + +**Measured** (`observation_reserve_bytes(frame, slots)` as `(active, token)`, before -> after): +1 KiB, >= 16 slots: (7306, 930) -> (6630, 930). 4 KiB, 1 slot: (4174, 310) -> (2396, 310); 4 KiB, +>= 51 slots: (26616, 3720) -> (27880, 3928). 16 KiB, 51 slots: (113118, 15810) -> (100164, 15810); +16 KiB, 64 slots: (113118, 15810) -> (101792, 15904); 16 KiB, 1000 slots: (113118, 15810) -> +(112992, 15904). The 4 KiB and 16 KiB tokens rise by 208 and 94 bytes because exact-byte admission +lets 13 keys of 120-byte IDs in at 4 KiB where the cap allowed 12 longest keys; that is the measured +rise (#1685 exact-byte admission). No retained byte row was pinned to these values: the capi +allocator oracle reads `observation_reserve_bytes`, the browser identity fixture has no observation +capacity (its rows are unchanged and within budget), and `retained_ceilings.rs` passed. No re-pin +was needed. + +**Departure (gate 1, 4 KiB).** A key takes at least 80 encoded bytes (nested TLV 8, message header +8, strip 16, section 16, effect 16, tap 16), so a 4 KiB frame holds about 50 short keys and 64 +cannot fit one event at 4 KiB by any exact count. D1 is frozen, so gate 1 is met at the browser's +16 KiB on both the browser and the core; at 4 KiB the core test proves the exact edge for the +64-track session (the arm that crosses is refused at its index with the codec's bytes, and exactly +the taps that fit are admitted). A C ABI host that wants 64 insert meters configures a frame of at +least about 5.2 KiB. The C ABI has no observe edit yet (#1584), so the C ABI side is the core +admission through `SessionState`. + +**Outside the authorized paths (mechanical).** `crates/control-plane/Cargo.toml` (+ `Cargo.lock`): +`bench-support` as a dev dependency, for gate 3's thread counters (allowed for `crates/` by +`check-bench-policy.sh`). `hosts/host-web/src/lib.rs`: the `debug_assert` that called +`observation_cap()` now compares `observation_ended_bytes()` with `observation_frame_bytes()`. + +**Gates.** + +1. Large session: `every_insert_of_a_sixty_four_track_session_subscribes_at_the_browser_frame` + (core, 16 KiB and 4 KiB), `tests::every_insert_of_a_sixty_four_track_session_subscribes` + (browser, 16 KiB, one submission of 64 records): pass. +2. Edge: `a_subscription_past_the_frame_is_refused_and_the_removal_of_all_still_commits` (core, + 1 KiB, longest IDs, `k` from the codec), `the_ended_bytes_are_the_encoded_event_of_the_armed_keys` + (IDs on each side of the alignment), browser + `tests::a_subscription_past_the_frame_is_refused_and_an_unsubscribe_makes_room`: pass. +3. Realtime: `admission_allocates_nothing` (`bench_support::alloc` thread counters after a warm-up; + arms, a `Limit` refusal and disarms): 0 allocations. +4. `cargo test --locked -p control-plane --features test-support`, `-p host-web --all-features`, + `-p host-core --all-features`, `-p capi`: pass. `scripts/check-web-audioworklet.sh`, + `scripts/check-browser-expected-resources.py`, `scripts/check-sdk-headless.sh` (559 evals), + `scripts/check-sdk-generated.sh`, `check-command-reason-vocabulary.py`, `realtime-policy`, + clippy `-D warnings`, `fmt --check`: pass. diff --git a/Cargo.lock b/Cargo.lock index 51e83fb0a..9bc4a95ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,6 +332,7 @@ dependencies = [ name = "control-plane" version = "0.1.0" dependencies = [ + "bench-support", "controls", "effect-contract", "engine", diff --git a/crates/control-plane/Cargo.toml b/crates/control-plane/Cargo.toml index 47b425d4e..fff033932 100644 --- a/crates/control-plane/Cargo.toml +++ b/crates/control-plane/Cargo.toml @@ -24,5 +24,9 @@ protocol.workspace = true session.workspace = true source.workspace = true +[dev-dependencies] +# #1685 gate 3: the admission allocation count, by the audited allocator's thread counters. +bench-support.workspace = true + [lints] workspace = true diff --git a/crates/control-plane/src/compile.rs b/crates/control-plane/src/compile.rs index a94323c78..6c080e0ba 100644 --- a/crates/control-plane/src/compile.rs +++ b/crates/control-plane/src/compile.rs @@ -308,17 +308,19 @@ pub(crate) fn capi_resources( // 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 (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. + // most slots any plan of the session has had, and the admitted keys' encoded bytes + // bound their count and text (`ended_event_ceiling`, #1676 D2, #1685 D4), not this + // plan's producers. The heap is allocated when a transaction prepares, never at the + // commit. checked_layout::>( queue_config.reliable_event_slots.get() + 1, )?, checked_byte_layout(cell_heap)?, // #1584 D6a: the binding table the session keeps for the newest plan and the transient // copies `ObservationBindings::after` makes of it while a rebuild prepares: at most one - // binding per observation slot, so at most `slots` bindings kept or ended, and as many - // protocol keys for the ended ones. A binding owns the text of its two IDs. + // binding per observation slot and no more than one ended event carries, kept or + // ended, and as many protocol keys for the ended ones. A binding owns the text of its two + // IDs (#1685 D4). checked_byte_layout(bindings_table)?, checked_byte_layout(bindings_transient)?, // #1584 D6a: the newest plan's pending-subscription slots and the first slot of each @@ -480,27 +482,81 @@ pub(crate) fn observation_resources( }) } -/// 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 { +/// The encoded size of an `OBSERVATION_ENDED` frame as a function of its keys (#1685 D1, D2): the +/// frame with no key, plus each key's own bytes. A key's bytes are a fixed part and the text of its +/// strip ID and effect ID, each padded to the codec's eight-byte value alignment; the tap ID and the +/// section are fixed width, and so are the frame header, the revision and the event sequence. The +/// fixed parts are measured with the real codec when the session compiles, and the model is checked +/// against the codec there ([`observation_wire`]), so admission computes a key's size from its two +/// lengths alone, allocating nothing, in the browser worklet's message handler. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ObservationWire { + /// The control frame limit: an event frame larger than this cannot be sent. + frame: usize, + /// The frame with no key. + base: usize, + /// A key's bytes besides the padded text of its two IDs. + key_fixed: usize, +} + +impl ObservationWire { + /// The encoded bytes of one key whose strip ID and effect ID are `strip` and `effect` bytes + /// long. + pub(crate) const fn key_bytes(self, strip: usize, effect: usize) -> usize { + self.key_fixed + strip.next_multiple_of(8) + effect.next_multiple_of(8) + } + + /// The encoded bytes of the frame that carries keys of `keys` bytes in all, or `0` for no key + /// (a commit that ends nothing emits no event). + pub(crate) const fn event_bytes(self, keys: usize) -> usize { + if keys == 0 { + 0 + } else { + self.base.saturating_add(keys) + } + } + + /// Whether keys of `keys` bytes in all fit one frame. + pub(crate) const fn fits(self, keys: usize) -> bool { + self.event_bytes(keys) <= self.frame + } + + /// The control frame limit. + pub(crate) const fn frame(self) -> usize { + self.frame + } + + /// The bytes the keys of one frame may take in all. + const fn room(self) -> usize { + self.frame.saturating_sub(self.base) + } + + /// The most keys one frame carries: every key at its shortest. + const fn max_keys(self) -> usize { + self.room() / self.key_bytes(1, 1) + } +} + +/// Measures [`ObservationWire`] for a control frame of `frame_bytes` with the real codec (#1685 +/// D2): the frame with one key and with two keys of eight-byte IDs give the fixed parts, and the +/// model is then checked against the codec for IDs that need padding and for IDs at the grammar's +/// longest. A codec whose sizes the model does not predict fails the compile, so admission can never +/// trust a wrong size. Control thread, once per session. +pub(crate) fn observation_wire(frame_bytes: u64) -> Result { let arithmetic = || failure(ResourceFault::Arithmetic); let codec = protocol::ProtocolCodec::default(); - let id = "a".repeat(session::STABLE_ID_MAXIMUM_BYTES); - let size = |count: usize| -> Option { + let size = |lengths: &[(usize, usize)]| -> Option { let event = protocol::ObservationEnded { 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(), + // Keys are strictly ascending on the wire; the tap ID, fixed width, orders keys + // whose IDs are equal. + keys: lengths + .iter() + .enumerate() + .map(|(tap, &(strip, effect))| protocol::ObservationTapKey { + strip_id: "a".repeat(strip), section: protocol::ObservationSection::Inserts, - effect_id: id.clone(), + effect_id: "a".repeat(effect), tap_id: u32::try_from(tap).unwrap_or(u32::MAX), }) .collect(), @@ -516,17 +572,60 @@ pub(crate) fn observation_binding_cap(frame_bytes: u64) -> Result None, } }; - 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) - .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)) + let one = size(&[(8, 8)]).ok_or_else(arithmetic)?; + let two = size(&[(8, 8), (8, 8)]).ok_or_else(arithmetic)?; + let key = two.checked_sub(one).ok_or_else(arithmetic)?; + let wire = ObservationWire { + frame: usize::try_from(frame_bytes).map_err(|_| failure(ResourceFault::Platform))?, + base: one.checked_sub(key).ok_or_else(arithmetic)?, + key_fixed: key.checked_sub(16).ok_or_else(arithmetic)?, + }; + let longest = session::STABLE_ID_MAXIMUM_BYTES; + for lengths in [ + // In wire order: strip, then effect (an ID of `a`s orders by its length). + &[(1, 1)][..], + &[(1, 127), (9, 16)], + &[(15, longest), (longest, 7), (longest, longest)], + ] { + let keys = lengths + .iter() + .map(|&(strip, effect)| wire.key_bytes(strip, effect)) + .sum::(); + if size(lengths) != Some(wire.base + keys) { + return Err(arithmetic()); + } + } + Ok(wire) +} + +/// The largest of `f` over `0..=keys` (#1685 D4), for an `f` that is linear between its +/// breakpoints: none, the four a table never holds fewer than, the counts on each side of the point +/// where the frame, not the longest IDs, starts to bound the text ([`admitted_text`]), and `keys`; +/// each clipped to `keys`. A piecewise-linear function peaks at a breakpoint or an end. +fn peak_over_keys( + wire: ObservationWire, + keys: usize, + f: impl Fn(usize) -> Option, +) -> Result { + // `admitted_text` turns where `count` longest IDs' text meets the frame's room less `count` + // fixed parts; the integers on each side of it. + let turn = wire.room() / (wire.key_fixed + 2 * session::STABLE_ID_MAXIMUM_BYTES); + let mut peak = 0_usize; + for count in [0, 4, turn, turn + 1, keys] { + peak = peak.max(f(count.min(keys)).ok_or_else(|| failure(ResourceFault::Arithmetic))?); + } + u64::try_from(peak).map_err(|_| failure(ResourceFault::Platform)) +} + +/// The ID text `count` admitted keys can own, at most (#1685 D4): two IDs of the grammar's longest +/// each, and never more than the frame's room leaves after their fixed parts, because an ID's text +/// is at most its padded encoded length and every admitted key fits one frame together. +fn admitted_text(wire: ObservationWire, count: usize) -> Option { + let longest = count.checked_mul(2 * session::STABLE_ID_MAXIMUM_BYTES)?; + let framed = wire + .room() + .saturating_sub(count.checked_mul(wire.key_fixed)?); + Some(longest.min(framed)) } /// The observation rows that are bounds, not allocations: nothing holds them until a host arms a @@ -546,16 +645,18 @@ fn observation_reserve( frame_bytes: u64, slots: usize, ) -> Result { - // 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)?; + // The table never holds more bindings than a plan has slots, and every binding it holds was + // admitted, so all of them together fit one ended event (#1685 D1). + let wire = observation_wire(frame_bytes)?; + let keys = slots.min(wire.max_keys()); + let ended_event = ended_event_ceiling(wire, keys)?; 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(bindings)?; + let (bindings_table, bindings_transient) = observation_binding_rows(wire, keys)?; Ok(ObservationReserve { ended_event, cell_heap, @@ -588,47 +689,44 @@ pub fn observation_reserve_bytes( )) } -/// 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)) +/// The heap one `OBSERVATION_ENDED` event can own when it ends at most `keys` taps (#1584 D6a, +/// #1685 D4): its key list, one `protocol::ObservationTapKey` each, and the text of the two IDs +/// every key owns, at most [`admitted_text`]. `keys` is what the session can hold, the smaller of +/// the observation slots it has had and the keys one frame carries. It does not depend on any +/// plan's producers: a cell can hold the keys of an earlier plan. +fn ended_event_ceiling(wire: ObservationWire, keys: usize) -> Result { + peak_over_keys(wire, keys, |count| { + count + .checked_mul(size_of::())? + .checked_add(admitted_text(wire, count)?) + }) } -/// What the binding table and a rebuild's transient copies of it hold for a plan with `slots` -/// observation slots, as `(table, transient)` bytes (#1584 D6a). The table's `Vec` grows by +/// What the binding table and a rebuild's transient copies of it hold for at most `keys` admitted +/// bindings, as `(table, transient)` bytes (#1584 D6a, #1685 D4). The table's `Vec` grows by /// insertion, so its capacity is at most twice its length (and at least four entries). The /// transient is what `ObservationBindings::after` builds from the table: each binding goes to the -/// kept table or the ended list, so at most `slots` of them together, and the protocol keys the -/// ended ones are copied into. Every binding and key owns the text of two IDs. -fn observation_binding_rows(slots: usize) -> Result<(u64, u64), CompileFailure> { - let arithmetic = || failure(ResourceFault::Arithmetic); - let text = 2 * session::STABLE_ID_MAXIMUM_BYTES; - let binding = size_of::() - .checked_add(text) - .ok_or_else(arithmetic)?; - let key = size_of::() - .checked_add(text) - .ok_or_else(arithmetic)?; - let table = if slots == 0 { 0 } else { slots.max(4) } - .checked_mul(2) - .and_then(|capacity| capacity.checked_mul(binding)) - .ok_or_else(arithmetic)?; - let transient = slots - .checked_mul(binding) - .and_then(|bytes| bytes.checked_add(slots.checked_mul(key)?)) - .ok_or_else(arithmetic)?; - Ok(( - u64::try_from(table).map_err(|_| failure(ResourceFault::Platform))?, - u64::try_from(transient).map_err(|_| failure(ResourceFault::Platform))?, - )) +/// kept table or the ended list, so at most `keys` of them together, and the protocol keys the +/// ended ones are copied into. The bindings' ID text and the keys' copy of it are each at most +/// [`admitted_text`]. +fn observation_binding_rows( + wire: ObservationWire, + keys: usize, +) -> Result<(u64, u64), CompileFailure> { + let binding = size_of::(); + let key = size_of::(); + let table = peak_over_keys(wire, keys, |count| { + if count == 0 { 0 } else { count.max(4) } + .checked_mul(2)? + .checked_mul(binding)? + .checked_add(admitted_text(wire, count)?) + })?; + let transient = peak_over_keys(wire, keys, |count| { + count + .checked_mul(binding.checked_add(key)?)? + .checked_add(admitted_text(wire, count)?.checked_mul(2)?) + })?; + Ok((table, transient)) } pub(crate) fn controller_retained_capacity( @@ -1205,16 +1303,21 @@ where observation_batch_counts: Box::default(), observation_batch_mark: 0, observation_armed: 0, - observation_cap: observation_binding_cap(limits.maximum_control_frame_bytes)?, + observation_wire: observation_wire(limits.maximum_control_frame_bytes)?, + observation_armed_bytes: 0, observation_slots_high_water: 0, }; - let (offsets, pending, counts, armed) = - crate::control::observation_slots(&session.providers.effects, &session.observation) - .map_err(|_| failure(ResourceFault::Allocation))?; + let (offsets, pending, counts, armed, armed_bytes) = crate::control::observation_slots( + &session.providers.effects, + &session.observation, + session.observation_wire, + ) + .map_err(|_| failure(ResourceFault::Allocation))?; session.observation_offsets = offsets; session.observation_pending = pending; session.observation_batch_counts = counts; session.observation_armed = armed; + session.observation_armed_bytes = armed_bytes; session.observation_slots_high_water = session.observation_pending.len(); Ok(CompiledChildren { session, diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index c543d6daa..5043a1a89 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -428,13 +428,16 @@ pub struct SessionState { /// `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`]. + /// included. 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, + /// The encoded bytes of the armed slots' keys in an `OBSERVATION_ENDED` event, pushes not yet + /// folded included: the figure a subscription is admitted against (#1685 D1). With the frame's + /// own bytes, never above the control frame limit. + pub(crate) observation_armed_bytes: usize, + /// The encoded size of an `OBSERVATION_ENDED` frame as a function of its keys, and the control + /// frame limit it must fit, so the commit that ends every binding can always say so (#1685 + /// D1). Fixed by the session's frame limit when it compiles. + pub(crate) observation_wire: crate::compile::ObservationWire, /// 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 @@ -488,14 +491,17 @@ pub enum ObservationKeyError { 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. +/// A subscription batch would leave armed taps whose keys one `OBSERVATION_ENDED` event cannot +/// carry in one control frame (#1676 D1, #1685 D1): the frame is full. 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. + /// The position, in the batch, of the first change whose arm makes the event outgrow the + /// frame. pub index: usize, - /// The most taps the session may have armed at once. - pub cap: usize, + /// The encoded bytes the event would need with that arm, every removal of the batch counted. + pub event_bytes: usize, + /// The control frame limit. + pub frame_bytes: usize, } /// One subscription change of a batch handed to @@ -515,8 +521,8 @@ pub struct ObservationChange { /// 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. + /// The batch would leave armed taps whose ended event outgrows one control frame. 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. @@ -578,16 +584,19 @@ pub(crate) type ObservationSlots = ( Box<[ObservationSlot]>, Box<[ObservationBatchCount]>, usize, + 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. +/// tap (#1676), and each slot carries its key's encoded bytes in an `OBSERVATION_ENDED` event +/// (#1685 D2), from the producer's two IDs. The last two figures are the armed slots and their keys' +/// bytes. 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, + wire: crate::compile::ObservationWire, ) -> Result { let mut offsets = Vec::new(); offsets.try_reserve_exact(producers.len() + 1)?; @@ -604,12 +613,14 @@ pub(crate) fn observation_slots( 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 + let mut armed_bytes = 0_usize; + for producer in producers { + // Every ID is at most `STABLE_ID_MAXIMUM_BYTES`, so a key is a few hundred bytes. + let key_bytes = wire.key_bytes(producer.track_id.len(), producer.effect_id.len()); + let key_bytes = u16::try_from(key_bytes).unwrap_or(u16::MAX); + for tap in 0..producer.observe_capacity() { + let is_armed = !bindings.is_empty() + && producer .descriptor .observations .get(tap) @@ -621,13 +632,16 @@ pub(crate) fn observation_slots( declared.id.0, ) }); - armed += usize::from(is_armed); - slots.push(ObservationSlot { - armed: is_armed, - pending: PendingArm::None, - mark: 0, - }); + armed += usize::from(is_armed); + if is_armed { + armed_bytes += usize::from(key_bytes); } + slots.push(ObservationSlot { + armed: is_armed, + pending: PendingArm::None, + mark: 0, + key_bytes, + }); } } Ok(( @@ -635,6 +649,7 @@ pub(crate) fn observation_slots( slots.into_boxed_slice(), counts.into_boxed_slice(), armed, + armed_bytes, )) } @@ -653,13 +668,16 @@ pub(crate) struct ObservationBatchCount { #[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. + /// admitted against the frame 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, + /// The encoded bytes of the tap's key in an `OBSERVATION_ENDED` event (#1685 D2): what arming + /// it adds to the event that would end every armed tap. Set when the plan's slots are built. + pub(crate) key_bytes: u16, } /// What the host last pushed to one observation slot (#1584 D6a). @@ -1817,6 +1835,7 @@ where let slots = match observation_slots( &candidate_provider.effects, &observation_change.bindings, + self.observation_wire, ) { Ok(slots) => slots, Err(_) => { @@ -1868,6 +1887,7 @@ where self.observation_pending, self.observation_batch_counts, self.observation_armed, + self.observation_armed_bytes, ) = slots; self.observation_slots_high_water = self .observation_slots_high_water @@ -2674,12 +2694,20 @@ where }) } - /// 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. + /// The encoded bytes of the one `OBSERVATION_ENDED` frame that would end every armed tap, + /// pushes the table has not folded yet included, or `0` when none is armed (#1685 D1). A + /// subscription is admitted only while this is at most [`Self::observation_frame_bytes`]. + #[must_use] + pub fn observation_ended_bytes(&self) -> usize { + self.observation_wire + .event_bytes(self.observation_armed_bytes) + } + + /// The control frame limit the ended event must fit (#1685 D1), the session's + /// `maximum_control_frame_bytes`. #[must_use] - pub fn observation_cap(&self) -> usize { - self.observation_cap + pub fn observation_frame_bytes(&self) -> usize { + self.observation_wire.frame() } /// How many taps are armed now, pushes the table has not folded yet included. @@ -2698,13 +2726,15 @@ where /// 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. + /// batch named is [`ObservationRefusal::Duplicate`], at the second naming. The frame check + /// below counts each tap once, so it is only sound for a batch that names each tap once. + /// 2. **The frame.** The batch is judged by the state it leaves: arms of taps that are not + /// armed add their key's encoded bytes, disarms of armed taps remove theirs, and the rest + /// change nothing (#1685 D1). A batch that would leave armed taps whose one + /// `OBSERVATION_ENDED` event, every key with its real IDs, outgrows the control frame + /// ([`Self::observation_frame_bytes`]) 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 @@ -2754,7 +2784,7 @@ where entry.mark = mark; } } - // Pass 2: the cap. + // Pass 2: the frame. self.admit_observation_changes(changes.clone()) .map_err(ObservationRefusal::Limit)?; // Pass 3: the room, one count per producer from pass 1. @@ -2783,10 +2813,10 @@ where .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 + self.observation_wire.fits(self.observation_armed_bytes), + "an admitted batch leaves an ended event of {} bytes past the frame of {}", + self.observation_ended_bytes(), + self.observation_frame_bytes() ); Ok(()) } @@ -2817,29 +2847,32 @@ where 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. + /// The frame check of [`Self::admit_and_push_observation_batch`] (#1685 D1): moves nothing and + /// allocates nothing; each key's bytes are its slot's. 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 slot = |change: &ObservationChange| -> Option<&ObservationSlot> { + self.observation_pending + .get(self.observation_slot(change.position, change.tap_index)?) }; 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); + .filter(|change| !change.armed) + .filter_map(|change| slot(&change).filter(|slot| slot.armed)) + .map(|slot| usize::from(slot.key_bytes)) + .sum::(); + let mut bytes_after = self.observation_armed_bytes.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 { + if let Some(slot) = slot(&change).filter(|slot| change.armed && !slot.armed) { + bytes_after = bytes_after.saturating_add(usize::from(slot.key_bytes)); + if !self.observation_wire.fits(bytes_after) { return Err(ObservationLimit { index, - cap: self.observation_cap, + event_bytes: self.observation_wire.event_bytes(bytes_after), + frame_bytes: self.observation_wire.frame(), }); } } @@ -2848,7 +2881,7 @@ where } /// 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 + /// way in (#1676 attempt 2): a test arms taps past the frame to prove that a commit whose /// ended event would not fit one frame is still refused. /// /// # Errors @@ -2867,7 +2900,7 @@ where /// Queues one observation record on the newest plan's effect producer at `position` of /// [`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 + /// [`Self::admit_and_push_observation_batch`], so no record is queued without the frame check /// (#1676 attempt 2). /// /// # Errors @@ -2906,11 +2939,19 @@ where } 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. + // The count and the bytes follow the slot's state (#1676, #1685): an arm of an + // armed tap only changes its window, and a disarm of a tap that is not armed + // removes nothing. + let key_bytes = usize::from(entry.key_bytes); match (entry.armed, armed) { - (false, true) => self.observation_armed += 1, - (true, false) => self.observation_armed -= 1, + (false, true) => { + self.observation_armed += 1; + self.observation_armed_bytes += key_bytes; + } + (true, false) => { + self.observation_armed -= 1; + self.observation_armed_bytes -= key_bytes; + } _ => {} } entry.armed = armed; diff --git a/crates/control-plane/tests/observation_bindings.rs b/crates/control-plane/tests/observation_bindings.rs index 56e6aa141..c74e5ec29 100644 --- a/crates/control-plane/tests/observation_bindings.rs +++ b/crates/control-plane/tests/observation_bindings.rs @@ -619,8 +619,8 @@ fn a_strip_removed_with_many_subscribed_inserts_gives_one_event_with_every_key() /// 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. 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 +/// is admitted against the frame (#1676, #1685), so a valid removal never reaches it; the test +/// pushes past the frame 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), @@ -675,7 +675,7 @@ 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. + // Past the frame on purpose: the defence below is only reachable without admission. rig.session .test_push_observe_unadmitted(position, 0, true, WINDOW_BLOCKS) .expect("a producer") @@ -881,59 +881,84 @@ impl Rig { } } -/// 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. +/// The encoded bytes of the one `OBSERVATION_ENDED` frame that ends the taps keyed by +/// `(strip, insert)` (tap 1, the compressor's gain reduction), measured with the real codec: the +/// oracle the session's exact-byte admission is held to (#1685). +fn ended_frame_bytes(keys: &[(String, String)]) -> usize { + let mut keys: Vec = keys + .iter() + .map(|(strip, effect)| protocol::ObservationTapKey { + strip_id: strip.clone(), + section: ObservationSection::Inserts, + effect_id: effect.clone(), + tap_id: 1, + }) + .collect(); + keys.sort_by(|a, b| (&a.strip_id, &a.effect_id).cmp(&(&b.strip_id, &b.effect_id))); + let event = protocol::ObservationEnded { + event_sequence: u64::MAX, + keys, + }; + match ProtocolCodec::default().encode_event_frame_into( + &protocol::TypedEventFrame { + revision: SessionRevision(u64::MAX), + payload: protocol::EventPayload::ObservationEnded(&event), + }, + &mut [], + ) { + Err(protocol::EncodeError::OutputTooSmall { required }) => required, + other => panic!("an ended event is measured: {other:?}"), + } +} + +/// The session's ended-event size is the real codec's size of the event that ends every armed +/// tap, whatever the IDs' lengths (#1685 D1, D2): IDs on each side of the codec's eight-byte +/// alignment and at the grammar's longest, after every arm and after a disarm; `0` with none armed. /// -/// 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. +/// Red if the session sizes a key by a fixed or longest-ID estimate, misses the padding of an ID, +/// leaves out or double-counts the frame's own bytes, or does not give a disarmed key's bytes back. #[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" +fn the_ended_bytes_are_the_encoded_event_of_the_armed_keys() { + let mut rig = Rig::new(); + let ids: Vec = [ + 0_usize, + 6, + 7, + 8, + 15, + 40, + session::STABLE_ID_MAXIMUM_BYTES - 1, + ] + .iter() + .map(|extra| format!("i{}", "x".repeat(*extra))) + .collect(); + rig.add_inserts(&ids); + assert_eq!(rig.session.observation_ended_bytes(), 0, "nothing armed"); + let mut armed = Vec::new(); + for id in &ids { + let position = rig.insert_position("comp1", id); + rig.observe(position, true).expect("the frame has room"); + armed.push(("comp1".to_owned(), id.clone())); + assert_eq!( + rig.session.observation_ended_bytes(), + ended_frame_bytes(&armed), + "{} armed keys", + armed.len() ); + rig.render(); } + let position = rig.insert_position("comp1", &ids[3]); + rig.observe(position, false).expect("a disarm"); + armed.retain(|(_, id)| id != &ids[3]); + assert_eq!( + rig.session.observation_ended_bytes(), + ended_frame_bytes(&armed) + ); + assert_eq!(rig.session.observation_frame_bytes(), 65_536); } /// `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. +/// a frame can carry. 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); @@ -989,37 +1014,59 @@ fn long_rig(count: usize) -> (Rig, Vec) { (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. +/// How many of [`long_rig`]'s taps one 1 KiB frame's ended event carries, by the real codec, and +/// the event's bytes for that many and for one more. +fn long_capacity() -> (usize, usize, usize) { + let keys = |count: usize| -> Vec<(String, String)> { + (0..count) + .map(|index| (long_strip(), long_insert(index))) + .collect() + }; + let k = (1..) + .take_while(|count| ended_frame_bytes(&keys(*count)) <= 1_024) + .last() + .expect("one longest key fits 1 KiB"); + ( + k, + ended_frame_bytes(&keys(k)), + ended_frame_bytes(&keys(k + 1)), + ) +} + +/// The edge (#1685 gate 2). Keys are sized so that one 1 KiB frame holds exactly `k` of them, by +/// the real codec: `k` arms are admitted, arm `k + 1` is refused `Limit` at its index, naming the +/// bytes it would need and the frame, before it is acknowledged and with nothing moved or queued; +/// a disarm later in the same batch makes room; and removing the strip commits with one +/// `OBSERVATION_ENDED` that holds all `k` keys and fits the frame. 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. +/// Red if the byte count is off by one key or by the frame header (arm `k + 1` is admitted, or arm +/// `k` refused), if a refused batch moves the armed bytes or queues a record, if an arm of an armed +/// tap counts again, if the refusal names the wrong change, if a disarm in the batch is not counted +/// against its arms, or if the commit can emit an event over one frame. #[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() +fn a_subscription_past_the_frame_is_refused_and_the_removal_of_all_still_commits() { + let (k, fits, over) = long_capacity(); + assert!(k >= 2, "the small frame still holds some keys: {k}"); + assert!(fits <= 1_024 && over > 1_024); + let limit = |index: usize| { + Err(ObservationRefusal::Limit(ObservationLimit { + index, + event_bytes: over, + frame_bytes: 1_024, + })) }; - 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"); + let (mut rig, positions) = long_rig(k + 1); + for position in &positions[..k] { + rig.observe(*position, true).expect("the frame has room"); } - assert_eq!(rig.session.observation_armed(), cap); + assert_eq!(rig.session.observation_armed(), k); + assert_eq!(rig.session.observation_ended_bytes(), fits); 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() @@ -1028,67 +1075,59 @@ fn a_subscription_past_the_cap_is_refused_and_the_removal_of_all_still_commits() .collect() }; let (bindings, free) = (rig.session.observation_bindings().clone(), room(&rig)); + assert_eq!(rig.observe(positions[k], true), limit(0)); 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 - })), + rig.batch(&[change(positions[1], true), change(positions[k], true)]), + limit(1), "the second change crosses: the first re-arms an armed tap" ); assert_eq!( rig.session.observation_armed(), - cap, + k, "a refusal moves nothing" ); + assert_eq!(rig.session.observation_ended_bytes(), fits); 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)]) + rig.batch(&[change(positions[k], true), change(positions[0], false)]) .expect("a disarm in the batch makes room"); - assert_eq!(rig.session.observation_armed(), cap); + assert_eq!(rig.session.observation_armed(), k); rig.render(); - // Remove every subscribed target: `cap` keys of the longest IDs, in one event in one frame. + // Remove every subscribed target: `k` keys in one event that fits 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); + assert_eq!(rig.session.observation_ended_bytes(), 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" - ); + assert_eq!(ended[0].1.keys.len(), k, "holding every key"); + let keys: Vec<(String, String)> = ended[0] + .1 + .keys + .iter() + .map(|key| (key.strip_id.clone(), key.effect_id.clone())) + .collect(); + assert_eq!(ended_frame_bytes(&keys), fits, "the event fits the frame"); - // 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); + // One tap more, 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_rig, positions) = long_rig(k + 1); for position in &positions { - over.session + over_rig + .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_eq!(over_rig.session.observation_ended_bytes(), over); + over_rig.render(); + let revision = over_rig.revision(); + let refused = over_rig.apply(&remove_long_strip()); assert!( matches!( refused, @@ -1096,15 +1135,133 @@ fn a_subscription_past_the_cap_is_refused_and_the_removal_of_all_still_commits() ResourceFault::Limit ))) ), - "cap + 1 longest keys do not fit one frame: {refused:?}" + "k + 1 longest keys do not fit one frame: {refused:?}" ); - assert_eq!(over.revision(), revision); + assert_eq!(over_rig.revision(), revision); } -/// A rebuild keeps the armed count of the taps it carries, so the cap holds across plans. +/// The fixture grown to 64 tracks, `t00` to `t63`, each with the compressor insert `comp` and a +/// route to the output, as a mixer session with a compressor on every track. +fn sixty_four_track_session() -> String { + let mut model = session::parse_session_json(SESSION).expect("fixture"); + let track = model.tracks[0].clone(); + let route = model.routes[0].clone(); + model.tracks = (0..64) + .map(|index| { + let mut track = track.clone(); + track.id = StableId::parse(&format!("t{index:02}")).expect("track"); + track + }) + .collect(); + model.routes = (0..64) + .map(|index| { + let mut route = route.clone(); + route.id = StableId::parse(&format!("t{index:02}-main")).expect("route"); + route.source = session::RouteSource::Track { + track_id: StableId::parse(&format!("t{index:02}")).expect("track"), + tap: session::SendTap::PostPan, + }; + route + }) + .collect(); + model.automation.clear(); + session::canonical_session_json(&model).expect("a valid session") +} + +/// The large session (#1685 gate 1), at the core every host calls: a 64-track session with one +/// compressor insert per track subscribes the compressor's meter tap on every insert, in one batch, +/// at the browser's 16 KiB frame: all 64 are admitted. At a 4 KiB frame, which a C ABI host may +/// configure, the event of 64 such keys does not fit (a key is at least 80 encoded bytes), so the +/// arm that crosses is refused at its index and exactly the taps whose keys fit are admitted +/// one by one. /// -/// 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. +/// Red if admission still assumes the longest IDs (the cap of #1676 refuses the 52nd tap at +/// 16 KiB), or if it admits an event over the frame at 4 KiB. +#[test] +fn every_insert_of_a_sixty_four_track_session_subscribes_at_the_browser_frame() { + let document = sixty_four_track_session(); + for frame in [16_384_u64, 4_096] { + let mut small = limits(); + small.maximum_control_frame_bytes = frame; + let mut rig = Rig::with_session(&document, small); + let positions: Vec = (0..64) + .map(|index| rig.insert_position(&format!("t{index:02}"), "comp")) + .collect(); + let keys: Vec<(String, String)> = (0..64) + .map(|index| (format!("t{index:02}"), "comp".to_owned())) + .collect(); + let fit = (1..=64) + .take_while(|count| ended_frame_bytes(&keys[..*count]) <= frame as usize) + .last() + .expect("one key fits"); + let all: Vec = positions.iter().map(|p| change(*p, true)).collect(); + if fit == 64 { + rig.batch(&all).expect("every insert subscribes"); + } else { + assert_eq!( + rig.batch(&all), + Err(ObservationRefusal::Limit(ObservationLimit { + index: fit, + event_bytes: ended_frame_bytes(&keys[..=fit]), + frame_bytes: frame as usize, + })), + "{frame}" + ); + assert_eq!(rig.session.observation_armed(), 0, "{frame}"); + rig.batch(&all[..fit]).expect("the taps that fit"); + } + assert_eq!(rig.session.observation_armed(), fit, "{frame}"); + assert_eq!( + rig.session.observation_ended_bytes(), + ended_frame_bytes(&keys[..fit]), + "{frame}" + ); + if frame == 16_384 { + assert_eq!(fit, 64, "the browser's frame holds every insert's tap"); + } + } +} + +/// Admission allocates nothing (#1685 gate 3): the browser runs it in the worklet's message +/// handler, on the audio thread. Arms, a refusal at the frame and disarms are admitted under the +/// calling thread's allocation counters, after one warm-up batch. +/// +/// Red if admission sizes a key by encoding it (into a buffer, or into owned keys), or allocates +/// scratch per batch. +#[test] +fn admission_allocates_nothing() { + let mut small = limits(); + small.maximum_control_frame_bytes = 4_096; + let mut rig = Rig::with_session(&sixty_four_track_session(), small); + let positions: Vec = (0..64) + .map(|index| rig.insert_position(&format!("t{index:02}"), "comp")) + .collect(); + let arms: Vec = positions.iter().map(|p| change(*p, true)).collect(); + let disarms: Vec = positions.iter().map(|p| change(*p, false)).collect(); + // Warm the statics and the queues' first touch. + rig.batch(&arms[..1]).expect("one arm"); + rig.render(); + rig.batch(&disarms[..1]).expect("one disarm"); + rig.render(); + let mark = bench_support::alloc::current_thread_counters(); + let refused = rig.batch(&arms); + let first = rig.batch(&arms[..32]); + // Disarms of other producers: a queue holds one record per tap until a render drains it. + let second = rig.batch(&disarms[32..48]); + let allocations = bench_support::alloc::current_thread_delta_since(mark).allocations; + assert!( + matches!(refused, Err(ObservationRefusal::Limit(_))), + "{refused:?}" + ); + assert_eq!((first, second), (Ok(()), Ok(()))); + assert_eq!(allocations, 0, "admission allocated"); +} + +/// A rebuild keeps the armed count and bytes of the taps it carries, so the frame bound holds across +/// plans. +/// +/// Red if a successor starts with an empty count or no bytes (admission would then let the event +/// outgrow the frame) 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(); @@ -1124,6 +1281,15 @@ fn the_armed_count_follows_the_bindings_across_a_rebuild() { .expect("the insert is removed"); assert_eq!(rig.session.observation_armed(), 2); assert_eq!(rig.session.observation_bindings().len(), 2); + let kept: Vec<(String, String)> = ["ins01", "ins02"] + .iter() + .map(|id| ("comp1".to_owned(), (*id).to_owned())) + .collect(); + assert_eq!( + rig.session.observation_ended_bytes(), + ended_frame_bytes(&kept), + "the successor's bytes are the kept keys'" + ); // 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] { @@ -1187,9 +1353,9 @@ fn the_reserve_follows_the_slots_and_a_shrunk_plan_keeps_the_larger_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 +/// are judged, and moves nothing (#1676, batch 13b fold). The frame check counts each tap's state +/// once, so `[disarm A, arm A, arm B]` with the frame full and `A` armed counts the disarm as a +/// removal, does not count the re-arm, and admits `B`: an event over the frame. 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. /// @@ -1197,11 +1363,7 @@ fn the_reserve_follows_the_slots_and_a_shrunk_plan_keeps_the_larger_reserve() { /// `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 (cap, _, _) = long_capacity(); let (mut rig, positions) = long_rig(cap + 1); for position in &positions[..cap] { rig.observe(*position, true).expect("under the cap"); @@ -1227,7 +1389,7 @@ fn a_batch_that_names_one_tap_twice_is_refused_at_the_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" + "two arms of one tap are refused as such, not as the frame" ); assert_eq!( rig.session.observation_armed(), diff --git a/docs/CONTROL_PROTOCOL_SEMANTICS.md b/docs/CONTROL_PROTOCOL_SEMANTICS.md index 22656ce05..66fab7c52 100644 --- a/docs/CONTROL_PROTOCOL_SEMANTICS.md +++ b/docs/CONTROL_PROTOCOL_SEMANTICS.md @@ -22,7 +22,7 @@ There is one way to make a change at a sample time: a stored automation edit (`S 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. 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. +**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 bounded so that this event always fits (#1676, #1685): a subscription is admitted only while the one `OBSERVATION_ENDED` frame that would carry the key of every armed tap, measured by its exact encoded bytes with the taps' real strip and effect IDs, fits within the control frame limit. A key takes at least 80 encoded bytes and grows with its IDs, so the browser's 16 KiB frame holds a meter tap on every insert of a 64-track session with short IDs. A subscription whose arm would make the event outgrow the frame 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; 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`, which computes each key's size from its two ID lengths and allocates nothing, so no record is queued without the frame 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, bounded by what the admitted keys' encoded bytes allow them to own. 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/hosts/host-web/src/lib.rs b/hosts/host-web/src/lib.rs index 160b29489..05e2191f7 100644 --- a/hosts/host-web/src/lib.rs +++ b/hosts/host-web/src/lib.rs @@ -864,7 +864,7 @@ pub const COMMAND_REASON_DOMAIN: u32 = 6; /// 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. Retryable: the -/// queue drains at the next render. Never the answer to the cap on armed taps, which is +/// queue drains at the next render. Never the answer to a full observation event, which is /// [`COMMAND_REASON_OBSERVATION_LIMIT`]. pub const COMMAND_REASON_BACKPRESSURE: u32 = 8; /// The host is not `STATE_READY`. @@ -885,14 +885,15 @@ 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. +/// The subscription would leave armed taps whose one `OBSERVATION_ENDED` event, every key with its +/// real IDs, does not fit one control frame (#1676, #1685): the frame is full, so the engine could +/// not tell the app honestly when a later edit ends them all. The record named is the arm that +/// made the event outgrow the frame, 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 +/// submission is admitted only after the app unsubscribes another tap. The admission is the control +/// plane's, by the event's exact encoded bytes against 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. @@ -4193,7 +4194,7 @@ fn admit_commands( // `ObservationUnbound` is "this preparation cannot deliver it", which is exactly what // `RESULT_UNSUPPORTED` means; `UnknownTap` is a bad address, like every other unknown. // `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. + // frame cannot carry the end of one more tap, and only an unsubscribe changes that. COMMAND_REASON_UNSUPPORTED_KIND | COMMAND_REASON_OBSERVATION_UNBOUND | COMMAND_REASON_OBSERVATION_LIMIT => RESULT_UNSUPPORTED, @@ -4284,11 +4285,12 @@ fn admit_commands( )); } } - // 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. + // The frame, the room and the push are one core call (#1676, #1685): the control plane judges + // the whole submission by the encoded bytes of the one `OBSERVATION_ENDED` event the taps it + // leaves armed would need, then by the room of every effect's observation queue, and only then + // queues it. A subscription whose event would outgrow the 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]; @@ -4324,10 +4326,10 @@ fn admit_commands( 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() + ready.control.observation_ended_bytes() <= ready.control.observation_frame_bytes(), + "a browser admission left an ended event of {} bytes past the frame of {}", + ready.control.observation_ended_bytes(), + ready.control.observation_frame_bytes() ); for entry in 0..staged_count { let staged = ready.command_decoded[entry]; diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index b73b80ba7..dfc39acbe 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -8037,41 +8037,137 @@ 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. +/// The encoded bytes of the one `OBSERVATION_ENDED` frame that ends the insert taps keyed by +/// `(strip, insert)` (tap 1), by the real codec: the oracle the control plane's exact-byte +/// admission is held to (#1685). +fn ended_frame_bytes(keys: &[(String, String)]) -> usize { + let mut keys: Vec = keys + .iter() + .map(|(strip, effect)| protocol::ObservationTapKey { + strip_id: strip.clone(), + section: protocol::ObservationSection::Inserts, + effect_id: effect.clone(), + tap_id: 1, + }) + .collect(); + keys.sort_by(|a, b| (&a.strip_id, &a.effect_id).cmp(&(&b.strip_id, &b.effect_id))); + let event = protocol::ObservationEnded { + event_sequence: u64::MAX, + keys, + }; + match protocol::ProtocolCodec::default().encode_event_frame_into( + &protocol::TypedEventFrame { + revision: protocol::SessionRevision(u64::MAX), + payload: protocol::EventPayload::ObservationEnded(&event), + }, + &mut [], + ) { + Err(protocol::EncodeError::OutputTooSmall { required }) => required, + other => panic!("an ended event is measured: {other:?}"), + } +} + +/// The large session (#1685 gate 1) on the browser: a 64-track session with one compressor insert +/// per track subscribes the compressor's meter tap on every insert, in one submission, at the +/// browser's 16 KiB control frame: all 64 are admitted, and the ended event they would need is the +/// real codec's size for their keys. +/// +/// Red if admission still assumes the longest IDs (the cap of #1676 held 51 taps at this frame and +/// refused record 51). +#[test] +fn every_insert_of_a_sixty_four_track_session_subscribes() { + const QUANTUM: u32 = 128; + const TRACKS: usize = 64; + let mut document: serde_json::Value = + serde_json::from_str(include_str!("../tests/browser-v1/observation-session.json")) + .expect("fixture"); + let track = document["tracks"][0].clone(); + let route = document["routes"][0].clone(); + let name = |index: usize| format!("t{index:02}"); + document["tracks"] = (0..TRACKS) + .map(|index| { + let mut track = track.clone(); + track["id"] = serde_json::Value::String(name(index)); + track + }) + .collect(); + document["routes"] = (0..TRACKS) + .map(|index| { + let mut route = route.clone(); + route["id"] = serde_json::Value::String(format!("{}-main", name(index))); + route["source"]["track_id"] = serde_json::Value::String(name(index)); + route + }) + .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"); + for track in 0..TRACKS { + stage_command( + &mut host, + track, + COMMAND_OBSERVE_SUBSCRIBE, + 1, + track as u32, + 0, + 1, + 2, + ); + } + host.submit_commands(TRACKS as u32); + let report = *host.command_report(); + assert_eq!(report.result, RESULT_OK, "{report:?}"); + let control = &host.ready.as_ref().expect("ready").control; + assert_eq!(control.observation_armed(), TRACKS); + let keys: Vec<(String, String)> = (0..TRACKS) + .map(|index| (name(index), "comp".to_owned())) + .collect(); + assert_eq!(control.observation_ended_bytes(), ended_frame_bytes(&keys)); + assert!(control.observation_ended_bytes() <= CONTROL_FRAME_BYTES as usize); +} + +/// A subscription whose `OBSERVATION_ENDED` event would outgrow the browser's 16 KiB control frame +/// (#1676, #1685) 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 admission is the control plane's, by the event's exact encoded bytes, 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. The insert IDs +/// are of the grammar's longest length, so the frame holds `k` of them by the real codec. /// -/// 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. +/// Red if the browser admits a subscription past the frame (the later removal of every subscribed +/// tap could not be told to its subscribers), if it reports the refusal 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() { +fn a_subscription_past_the_frame_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 insert_id = |index: usize| { + let id = format!( + "i{index:03}{}", + "x".repeat(session::STABLE_ID_MAXIMUM_BYTES - 4) + ); + assert_eq!(id.len(), session::STABLE_ID_MAXIMUM_BYTES); + id + }; + let keys = |count: usize| -> Vec<(String, String)> { + (0..count) + .map(|index| ("track".to_owned(), insert_id(index))) + .collect() }; + let frame = CONTROL_FRAME_BYTES as usize; + let probe_cap = (1..) + .take_while(|count| ended_frame_bytes(&keys(*count)) <= frame) + .last() + .expect("one key fits"); let inserts = probe_cap + 2; let mut document = document; let template = document["tracks"][0]["inserts"]["effects"][0].clone(); @@ -8079,7 +8175,7 @@ fn a_subscription_past_the_cap_is_refused_and_an_unsubscribe_makes_room() { (0..inserts) .map(|index| { let mut effect = template.clone(); - effect["id"] = serde_json::Value::String(format!("ins{index:03}")); + effect["id"] = serde_json::Value::String(insert_id(index)); effect }) .collect(), @@ -8128,6 +8224,14 @@ fn a_subscription_past_the_cap_is_refused_and_an_unsubscribe_makes_room() { assert_eq!(report.result, RESULT_OK, "{effect}: {report:?}"); } assert_eq!(armed(&host), probe_cap); + assert_eq!( + host.ready + .as_ref() + .expect("ready") + .control + .observation_ended_bytes(), + ended_frame_bytes(&keys(probe_cap)) + ); let refused = submit(&mut host, COMMAND_OBSERVE_SUBSCRIBE, probe_cap as u32); assert_eq!( ( @@ -8145,7 +8249,7 @@ fn a_subscription_past_the_cap_is_refused_and_an_unsubscribe_makes_room() { "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, + // A batch that unsubscribes one tap, takes its place and then crosses the frame 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); 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 f2fe46311..3eba010b5 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 @@ -235,7 +235,7 @@ export const enum MisoCommandReason { /// 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`. + /// Never the answer to a full observation event, which is `ObservationLimit`. Backpressure = 8, /// The engine is not `STATE_READY`. WrongState = 9, @@ -252,9 +252,9 @@ export const enum MisoCommandReason { // 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`. + /// `OBSERVATION_ENDED` event in one control frame (issue 1685: the event's exact encoded bytes + /// against the browser's 16 KiB frame). `rejectedIndex` names the subscription that crossed it, + /// 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. diff --git a/scripts/test-web-audioworklet.mjs b/scripts/test-web-audioworklet.mjs index d5b822213..c15836895 100644 --- a/scripts/test-web-audioworklet.mjs +++ b/scripts/test-web-audioworklet.mjs @@ -2295,9 +2295,9 @@ 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 + // Issue #1676 (#1685: by exact bytes): past what one ended event holds. 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" }, + { reason: 15, result: 7, what: "a subscription past what one ended event holds" }, ]) { commandResult = result; commandMutation = (response) => ({ ...response, reason, rejectedIndex: 0, admitted: 0 }); diff --git a/sdk/src/browser/shipped-host.d.ts b/sdk/src/browser/shipped-host.d.ts index f2fe46311..3eba010b5 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -235,7 +235,7 @@ export const enum MisoCommandReason { /// 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`. + /// Never the answer to a full observation event, which is `ObservationLimit`. Backpressure = 8, /// The engine is not `STATE_READY`. WrongState = 9, @@ -252,9 +252,9 @@ export const enum MisoCommandReason { // 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`. + /// `OBSERVATION_ENDED` event in one control frame (issue 1685: the event's exact encoded bytes + /// against the browser's 16 KiB frame). `rejectedIndex` names the subscription that crossed it, + /// 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. diff --git a/sdk/src/core/observation-subscriptions.ts b/sdk/src/core/observation-subscriptions.ts index 1774d2bd6..59b86e9ce 100644 --- a/sdk/src/core/observation-subscriptions.ts +++ b/sdk/src/core/observation-subscriptions.ts @@ -448,10 +448,10 @@ function projectRow(row: ObservationReadResult, channels: ObservationChannels): function commandFailure(message: string, report: CommandReport): MisoEngineError { // 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. + // (#1676, #1685) is a full frame: the one event that would end every armed tap no longer fits one + // control frame. 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)" + ? " (the control frame that would end the 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", @@ -1071,7 +1071,7 @@ export class ObservationSubscriptionOwner { // 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 or one - // past the cap on armed taps (`observationLimit`, #1676) included, never succeeds on resend + // past a full frame of armed taps (`observationLimit`, #1676, #1685) included, never succeeds on resend // and is thrown at once. if (report.reasonName !== "backpressure" || wait === undefined || attempt >= MAXIMUM_ADMISSION_RETRIES) { diff --git a/sdk/test/observation-subscription-evals.mjs b/sdk/test/observation-subscription-evals.mjs index a3e36c908..cc9312386 100644 --- a/sdk/test/observation-subscription-evals.mjs +++ b/sdk/test/observation-subscription-evals.mjs @@ -691,10 +691,11 @@ describe("issue 1345 -- the browser engine waits for a render, within a bound", } }); - // #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 () => { + // #1676, #1685: a full frame of 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), if + // the thrown error does not carry the reason as its diagnostic code, or if the message does not + // say the frame is full. + test("a subscription past a full frame 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 { @@ -707,10 +708,10 @@ describe("issue 1345 -- the browser engine waits for a render, within a bound", assert.ok(error instanceof MisoEngineError); assert.equal(error.code, "unsupported"); assert.equal(error.diagnosticCode, "observationLimit"); - assert.match(error.message, /unsubscribe another tap/); + assert.match(error.message, /control frame .* is full; unsubscribe another tap/); return true; }); - assert.ok(Date.now() - started < 500, "the cap refusal does not wait for a render"); + assert.ok(Date.now() - started < 500, "the full-frame refusal does not wait for a render"); assert.equal(rig.log.commands.length, 1, "the one refused subscription, no resend"); } finally { await engine.close(); From 360a0ce2b28e9886b231618f4a8028bc36512ee9 Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 18:37:17 +0000 Subject: [PATCH 02/10] Remove the redundant span kind and end from prepared automation spans (#1686) Co-Authored-By: Claude Opus 5.5 --- ...-and-end-from-prepared-automation-spans.md | 36 ++++++++ crates/compressor/src/lib.rs | 35 ++++---- crates/compressor/src/padding_tests.rs | 14 ++- crates/compressor/tests/knee_overflow.rs | 11 +-- crates/compressor/tests/lane_identity.rs | 16 ++-- crates/compressor/tests/mono_collapse.rs | 11 +-- crates/compressor/tests/native_points.rs | 13 ++- crates/compressor/tests/partition.rs | 17 ++-- crates/compressor/tests/payload.rs | 11 +-- crates/compressor/tests/ramps.rs | 46 +++------- crates/compressor/tests/silent_fixed_point.rs | 18 ++-- crates/compressor/tests/support/mod.rs | 6 +- crates/conformance/src/effect.rs | 32 +++---- crates/conformance/src/ramp_endpoint.rs | 18 ++-- crates/conformance/src/randomized.rs | 52 +++++------ crates/conformance/tests/effect_contract.rs | 30 +++---- crates/delay/src/corpus.rs | 13 ++- crates/delay/src/lib.rs | 44 +++------ .../effect-compiler/tests/native_session.rs | 7 +- crates/effect-contract/src/lib.rs | 67 +++++--------- crates/effect-contract/src/live.rs | 19 ++-- crates/effect-contract/tests/live_control.rs | 23 ++--- crates/effect-contract/tests/live_window.rs | 2 +- .../effect-contract/tests/support/lane_rig.rs | 22 ++--- crates/gate-expander/src/lib.rs | 31 +++---- crates/gate-expander/src/padding_tests.rs | 14 +-- crates/gate-expander/tests/support/mod.rs | 15 ++-- crates/graph/src/lib.rs | 11 +-- crates/graph/src/runtime.rs | 7 +- crates/host-core/tests/effect_observation.rs | 15 ++-- crates/multiband-compressor/src/corpus.rs | 15 ++-- crates/multiband-compressor/src/lib.rs | 33 ++++--- crates/multiband-compressor/src/split.rs | 21 ++--- .../multiband-compressor/tests/support/mod.rs | 11 +-- crates/parametric-eq/src/lib.rs | 17 ++-- crates/parametric-eq/tests/contract.rs | 8 +- crates/parametric-eq/tests/support/mod.rs | 15 ++-- crates/rack/src/lib.rs | 7 +- crates/rack/tests/live_control_bank.rs | 17 ++-- crates/soft-clip/src/lib.rs | 15 ++-- crates/soft-clip/src/padding_tests.rs | 21 ++--- crates/soft-clip/tests/contract.rs | 13 +-- crates/soft-clip/tests/support/mod.rs | 7 +- crates/transient-shaper/src/lib.rs | 30 +++---- crates/transient-shaper/src/padding_tests.rs | 14 +-- crates/transient-shaper/tests/common/mod.rs | 11 +-- crates/true-peak-limiter/src/lib.rs | 89 +++++++------------ crates/true-peak-limiter/tests/allocation.rs | 14 ++- .../true-peak-limiter/tests/mono_collapse.rs | 11 +-- crates/true-peak-limiter/tests/padding.rs | 14 ++- docs/EFFECT_CONTRACT_V1.md | 18 ++-- tools/audit/src/compressor.rs | 24 ++--- tools/audit/src/delay.rs | 14 ++- tools/console-workload/src/lib.rs | 4 +- tools/console-workload/tests/paired_spans.rs | 36 ++++---- 55 files changed, 433 insertions(+), 702 deletions(-) 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 index e719054c6..146891cb7 100644 --- 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 @@ -91,3 +91,39 @@ no test has to check one. ## Dependencies - #1636 (batch 13b). + +## Decision record: #1686 (batch 14a) + +**Shipped.** `PreparedAutomationSpan` is `{channel, parameter_index, sample, value}`. `AutomationSpanKind` +and its alias `AutomationKind` are deleted (nothing else read them; `protocol`'s own `AutomationKind` was +already gone with #1629). The retired-values note for kinds 2 to 4 now sits in the doc comment of the +struct, beside the `AutomationRate` note that stays. `valid_runtime_span` is the conjunction of the +remaining checks (finite value, sample inside the block, value in the domain, channel against the +channel policy). `validate_automation_block`'s ordering rule compares a span with the previous span's +`sample` and `value` (same verdict for every valid point). Every effect's span accept rule lost the +kind/end terms (they were constant true for a representable span). `docs/EFFECT_CONTRACT_V1.md` names +one sample and one value. + +**Departures.** None from the frozen decisions. Tests that only checked a removed field: +- `delay`: `malformed_automation_is_counted_and_never_applied` lost its "end differs from start" row + (count 5 to 4). +- `soft-clip` `automation_rejects_everything_but_canonical_ordered_points` lost its `ranged` row (3 invalid). +- `parametric-eq` `malformed_automation_rejects_each_span_without_losing_valid_targets` lost two rows + (8 to 6; the EQ counts every raw span). +- `conformance/tests/effect_contract.rs` span-validation fuzz: modulus 5 to 4; the end-only cases are + replaced by one non-finite value case. +- `effect-contract` `retired_automation_values_are_refused` lost the `AutomationSpanKind` asserts; + `live_control` and `rack` lost their kind/end asserts. +No retained byte row or fixture byte count moved (the estimate reads `size_of::()`). + +**Gates.** +1. `./target/release/audit capi`: base `pcm_digest` `bf7dcedd1d92621f`, head `bf7dcedd1d92621f` + (allocations 0, total_violations 0). +2. Contract truth table: `crates/effect-contract` unit tests and `crates/conformance/tests/effect_contract.rs` + keep non-finite value, sample outside the block, value outside the domain and channel-policy rows; + ordering rows unchanged. Randomized conformance run passes. +3. `cargo test --locked` for effect-contract, effect-compiler, conformance, rack, graph, graph-compiler, + the eight native effect crates, capi, host-core (--all-features), host-web (--all-features), + console-workload, parameter-metadata, audit: all pass. `check-effect-runtime-policy.sh`, + `check-workspace-policy.sh`, `check-effect-runtime-fixtures.sh`, `realtime-policy`: ok. + `cargo clippy --workspace --all-targets --all-features -- -D warnings` and `cargo fmt --all --check`: clean. diff --git a/crates/compressor/src/lib.rs b/crates/compressor/src/lib.rs index 52c1bf78d..eeb1be39b 100644 --- a/crates/compressor/src/lib.rs +++ b/crates/compressor/src/lib.rs @@ -39,18 +39,17 @@ mod state; pub mod corpus; use effect_contract::{ - AutomationRate, AutomationSpanKind, BankProcessReport, BankWidth, EffectBankProcessBlock, - EffectDescriptor, EffectPrepareError, EffectProcessBlock, EffectQuality, InitialParameterValue, - LatencySamples, LinkMode, LinkModeSet, NativeEffectFactory, ObservationCadence, - ObservationChannels, ObservationCost, ObservationDescriptor, ObservationFold, ObservationKind, - ObservationSample, ObservationTapId, ParameterAccessError, ParameterChannel, - ParameterChannelPolicy, ParameterDescriptor, ParameterDomain, ParameterId, ParameterMapping, - ParameterUnit, PortDescriptor, PortId, PortLayout, PortRole, PrepareEffectBankRequest, - PrepareEffectRequest, PreparedAutomationSpan, PreparedBankMetadata, PreparedEffect, - PreparedEffectBank, PreparedEffectMetadata, PreparedNativeEffect, PreparedNativeEffectBank, - PreparedParameterState, ProcessReport, ResetKind, SmoothingRule, StatePayloadError, - StatePayloadInput, StatePayloadOutput, StatePayloadSizes, TailSamples, - expected_prepared_metadata, + AutomationRate, BankProcessReport, BankWidth, EffectBankProcessBlock, EffectDescriptor, + EffectPrepareError, EffectProcessBlock, EffectQuality, InitialParameterValue, LatencySamples, + LinkMode, LinkModeSet, NativeEffectFactory, ObservationCadence, ObservationChannels, + ObservationCost, ObservationDescriptor, ObservationFold, ObservationKind, ObservationSample, + ObservationTapId, ParameterAccessError, ParameterChannel, ParameterChannelPolicy, + ParameterDescriptor, ParameterDomain, ParameterId, ParameterMapping, ParameterUnit, + PortDescriptor, PortId, PortLayout, PortRole, PrepareEffectBankRequest, PrepareEffectRequest, + PreparedAutomationSpan, PreparedBankMetadata, PreparedEffect, PreparedEffectBank, + PreparedEffectMetadata, PreparedNativeEffect, PreparedNativeEffectBank, PreparedParameterState, + ProcessReport, ResetKind, SmoothingRule, StatePayloadError, StatePayloadInput, + StatePayloadOutput, StatePayloadSizes, TailSamples, expected_prepared_metadata, }; use effect_runtime::bank::{block_is_positive_zero, check_block, nonfinite_lane_mask}; use effect_runtime::params::{is_negative_zero, normalize_zero, parameter_value_valid}; @@ -419,8 +418,7 @@ fn initial_defaults( /// Applies one lane's automation spans to its ramps. /// /// Validation is unchanged from V1 and deliberately strict: a span must be inside the prepared -/// automation capacity, a `Point` at exactly `first_sample`, with `start_value` and `end_value` -/// bit-identical, inside the parameter's domain, addressed to a single channel, in strictly +/// automation capacity, a span at exactly `first_sample`, inside the parameter's domain, addressed to a single channel, in strictly /// increasing `(parameter, channel)` order, and not a duplicate. Anything else is counted in /// `invalid_spans` and ignored — never applied partially. fn apply_automation( @@ -455,11 +453,8 @@ fn apply_automation( }; let valid = span_index < automation_capacity as usize && parameter_index < RAMP_COUNT - && span.kind == AutomationSpanKind::Point - && span.start_sample == first_sample - && span.end_sample == first_sample - && span.start_value.to_bits() == span.end_value.to_bits() - && parameter_value_valid(&PARAMETER_SPECS[parameter_index], span.start_value) + && span.sample == first_sample + && parameter_value_valid(&PARAMETER_SPECS[parameter_index], span.value) && last_order.is_none_or(|previous| order > previous) && pending[channel_index][parameter_index].is_none(); if !valid { @@ -467,7 +462,7 @@ fn apply_automation( continue; } last_order = Some(order); - pending[channel_index][parameter_index] = Some(normalize_zero(span.start_value)); + pending[channel_index][parameter_index] = Some(normalize_zero(span.value)); } for (parameter_index, (left_value, right_value)) in pending[0].iter().zip(pending[1].iter()).enumerate() diff --git a/crates/compressor/src/padding_tests.rs b/crates/compressor/src/padding_tests.rs index ff1e0c96d..8ec7824bf 100644 --- a/crates/compressor/src/padding_tests.rs +++ b/crates/compressor/src/padding_tests.rs @@ -19,8 +19,8 @@ use super::*; use dsp_reference::class_a; use dsp_reference::randomized::{Draw, Profile, run_seeds}; use effect_contract::{ - AutomationSpanKind, EffectProcessBlock, EffectQuality, LinkMode, PrepareEffectLimits, - PreparedPorts, PreparedSidechainPort, + EffectProcessBlock, EffectQuality, LinkMode, PrepareEffectLimits, PreparedPorts, + PreparedSidechainPort, }; const QUANTUM: u32 = 128; @@ -185,13 +185,10 @@ fn draw_spans(draw: &mut Draw, mono: bool) -> Vec { let spec = &COMPRESSOR_PARAMETERS[parameter]; let value = draw.in_domain(spec.minimum.unwrap(), spec.maximum.unwrap()); let span = |channel| PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: parameter as u32, - start_sample: 0, - end_sample: 0, - start_value: value, - end_value: value, + sample: 0, + value, }; if draw.chance(1, 12) { spans.push(span(ParameterChannel::Both)); @@ -287,8 +284,7 @@ fn at(spans: &[PreparedAutomationSpan], first_sample: u64) -> Vec PreparedAutomationSpan { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: 2, - start_sample: 0, - end_sample: 0, - start_value: value, - end_value: value, + sample: 0, + value, } } diff --git a/crates/compressor/tests/lane_identity.rs b/crates/compressor/tests/lane_identity.rs index 968cfceab..00d6277a7 100644 --- a/crates/compressor/tests/lane_identity.rs +++ b/crates/compressor/tests/lane_identity.rs @@ -15,7 +15,7 @@ mod support; use compressor::corpus::{CASE_NAMES, POINTS, run_case}; -use effect_contract::{AutomationSpanKind, ParameterChannel, PreparedAutomationSpan}; +use effect_contract::{ParameterChannel, PreparedAutomationSpan}; use support::{ bind_bank, native_bank_width, noise, prepare, render_bank, render_scalar, request_with_quantum, snapshot, snapshot_track, values_with, @@ -64,26 +64,20 @@ fn bank_matches_scalar_per_lane_bits() { 3 * 128_u64, 1_usize, PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 0, - start_sample: 0, - end_sample: 0, - start_value: -44.0, - end_value: -44.0, + sample: 0, + value: -44.0, }, ), ( 9 * 128_u64, lanes - 1, PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Right, parameter_index: 6, - start_sample: 0, - end_sample: 0, - start_value: 0.9, - end_value: 0.9, + sample: 0, + value: 0.9, }, ), ]; diff --git a/crates/compressor/tests/mono_collapse.rs b/crates/compressor/tests/mono_collapse.rs index 5ba78370c..ba0e234e2 100644 --- a/crates/compressor/tests/mono_collapse.rs +++ b/crates/compressor/tests/mono_collapse.rs @@ -12,8 +12,8 @@ mod support; use effect_contract::{ - AutomationSpanKind, BankWidth, EffectBankProcessBlock, EffectQuality, InitialParameterValue, - LinkMode, ParameterChannel, PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, + BankWidth, EffectBankProcessBlock, EffectQuality, InitialParameterValue, LinkMode, + ParameterChannel, PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, PreparedNativeEffectBank, PreparedPorts, PreparedSidechainPort, }; @@ -105,13 +105,10 @@ fn automation(step: usize, lanes: usize) -> (Vec, Vec PreparedAutomationSpan { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: parameter, - start_sample: sample, - end_sample: sample, - start_value: value, - end_value: value, + sample, + value, } } diff --git a/crates/compressor/tests/partition.rs b/crates/compressor/tests/partition.rs index 8e8df0b0d..788160f72 100644 --- a/crates/compressor/tests/partition.rs +++ b/crates/compressor/tests/partition.rs @@ -12,8 +12,7 @@ mod support; use effect_contract::{ - AutomationSpanKind, EffectProcessBlock, LinkMode, ParameterChannel, PreparedAutomationSpan, - PreparedSidechainPort, + EffectProcessBlock, LinkMode, ParameterChannel, PreparedAutomationSpan, PreparedSidechainPort, }; use support::{ @@ -34,13 +33,10 @@ fn every_parameter() -> Vec<(u64, PreparedAutomationSpan)> { spans.push(( 0_u64, PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: parameter as u32, - start_sample: 0, - end_sample: 0, - start_value: value, - end_value: value, + sample: 0, + value, }, )); } @@ -137,13 +133,10 @@ fn bank_block_partitions_are_invariant() { 0_u64, track, PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 0, - start_sample: 0, - end_sample: 0, - start_value: -30.0 - track as f32, - end_value: -30.0 - track as f32, + sample: 0, + value: -30.0 - track as f32, }, ) }) diff --git a/crates/compressor/tests/payload.rs b/crates/compressor/tests/payload.rs index 315cbcc8f..60d14b5a1 100644 --- a/crates/compressor/tests/payload.rs +++ b/crates/compressor/tests/payload.rs @@ -7,9 +7,7 @@ mod support; -use effect_contract::{ - AutomationSpanKind, ParameterChannel, PreparedAutomationSpan, ResetKind, StatePayloadInput, -}; +use effect_contract::{ParameterChannel, PreparedAutomationSpan, ResetKind, StatePayloadInput}; use effect_runtime::state_payload::{read_f32, read_u32, write_f32, write_u32}; use compressor::COMPRESSOR_PARAMETERS; @@ -21,13 +19,10 @@ use support::{ fn point(parameter: u32, channel: ParameterChannel, value: f32) -> PreparedAutomationSpan { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: parameter, - start_sample: 0, - end_sample: 0, - start_value: value, - end_value: value, + sample: 0, + value, } } diff --git a/crates/compressor/tests/ramps.rs b/crates/compressor/tests/ramps.rs index 373a096c1..cb4df6ea3 100644 --- a/crates/compressor/tests/ramps.rs +++ b/crates/compressor/tests/ramps.rs @@ -8,9 +8,7 @@ mod support; -use effect_contract::{ - AutomationSpanKind, ParameterChannel, PreparedAutomationSpan, StatePayloadOutput, -}; +use effect_contract::{ParameterChannel, PreparedAutomationSpan, StatePayloadOutput}; use effect_runtime::state_payload::{read_f32, read_u32}; use support::{ @@ -24,13 +22,10 @@ const SMOOTHING: u32 = 64; fn point(parameter: u32, channel: ParameterChannel, value: f32) -> PreparedAutomationSpan { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: parameter, - start_sample: 0, - end_sample: 0, - start_value: value, - end_value: value, + sample: 0, + value, } } @@ -182,8 +177,7 @@ fn an_attack_cancel_to_current_keeps_the_64_sample_coefficient_return_live() { let current = read_f32(&before, current_word); let cancel = PreparedAutomationSpan { - start_sample: 19, - end_sample: 19, + sample: 19, ..point(3, ParameterChannel::Left, current) }; let mut left = [0.0_f32; 1]; @@ -415,7 +409,7 @@ fn bank_lane_bits_from( let mut bank = support::bind_bank(&requests).expect("bank must bind at this build's width"); // Ascending parameter index within a track, because the contract's canonical span order is - // `(start_sample, parameter_index, channel)` and a block that presents two spans the other + // `(sample, parameter_index, channel)` and a block that presents two spans the other // way round has its second one counted as invalid and dropped rather than applied. Sorted // here rather than demanded of every caller: a silently dropped span is a test that quietly // stops testing what it says it does, which is exactly what it cost to find this once. @@ -436,13 +430,10 @@ fn bank_lane_bits_from( for (at, lane, parameter, value) in &ordered { if *lane == track && *at == block { flat.push(PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: *parameter, - start_sample: first_sample, - end_sample: first_sample, - start_value: *value, - end_value: *value, + sample: first_sample, + value: *value, }); } } @@ -580,24 +571,18 @@ fn silent_ramp_bits(suppress: bool) -> Vec { for track in 0..lanes { if suppress { flat.push(PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 0, - start_sample: first_sample, - end_sample: first_sample, - start_value: SILENT_THRESHOLD_DB, - end_value: SILENT_THRESHOLD_DB, + sample: first_sample, + value: SILENT_THRESHOLD_DB, }); } if track == RAMP_LANE && block == SILENT_AUTOMATED_BLOCK { flat.push(PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Right, parameter_index: 0, - start_sample: first_sample, - end_sample: first_sample, - start_value: RAMP_TARGET_DB, - end_value: RAMP_TARGET_DB, + sample: first_sample, + value: RAMP_TARGET_DB, }); } offsets[track + 1] = flat.len() as u32; @@ -770,17 +755,14 @@ fn process_bank_points( let mut spans = Vec::new(); let mut offsets = vec![0_u32; lanes + 1]; for offset in offsets.iter_mut().skip(1) { - // Canonical span order within a track: `(start_sample, parameter_index, channel)`. + // Canonical span order within a track: `(sample, parameter_index, channel)`. for (parameter, value) in points { for channel in [ParameterChannel::Left, ParameterChannel::Right] { spans.push(PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: *parameter, - start_sample: first_sample, - end_sample: first_sample, - start_value: *value, - end_value: *value, + sample: first_sample, + value: *value, }); } } diff --git a/crates/compressor/tests/silent_fixed_point.rs b/crates/compressor/tests/silent_fixed_point.rs index 17d3f2a2a..acf88dac3 100644 --- a/crates/compressor/tests/silent_fixed_point.rs +++ b/crates/compressor/tests/silent_fixed_point.rs @@ -21,9 +21,7 @@ mod support; -use effect_contract::{ - AutomationSpanKind, EffectBankProcessBlock, ParameterChannel, PreparedAutomationSpan, -}; +use effect_contract::{EffectBankProcessBlock, ParameterChannel, PreparedAutomationSpan}; use support::{bind_bank, native_bank_width, request, values_with}; const FRAMES: usize = 128; @@ -98,13 +96,10 @@ fn render_with( let restated: Vec = if restate { (0..lanes) .map(|_| PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 0, - start_sample: first_sample, - end_sample: first_sample, - start_value: threshold_db, - end_value: threshold_db, + sample: first_sample, + value: threshold_db, }) .collect() } else { @@ -249,13 +244,10 @@ fn negative_zero_blocks_match_the_forced_slow_path() { let restated: Vec = if restate { (0..lanes) .map(|_| PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 0, - start_sample: first_sample, - end_sample: first_sample, - start_value: THRESHOLD_DB, - end_value: THRESHOLD_DB, + sample: first_sample, + value: THRESHOLD_DB, }) .collect() } else { diff --git a/crates/compressor/tests/support/mod.rs b/crates/compressor/tests/support/mod.rs index 062d8453b..12a136d66 100644 --- a/crates/compressor/tests/support/mod.rs +++ b/crates/compressor/tests/support/mod.rs @@ -168,8 +168,7 @@ pub fn render_scalar( .filter(|(at, _)| *at >= offset as u64 && *at < end as u64) .map(|(_, span)| { let mut span = *span; - span.start_sample = offset as u64; - span.end_sample = offset as u64; + span.sample = offset as u64; span }) .collect(); @@ -213,8 +212,7 @@ pub fn render_bank( for (at, lane, span) in spans { if *lane == track && *at >= offset as u64 && *at < end as u64 { let mut span = *span; - span.start_sample = offset as u64; - span.end_sample = offset as u64; + span.sample = offset as u64; flat.push(span); } } diff --git a/crates/conformance/src/effect.rs b/crates/conformance/src/effect.rs index a07917918..57a6b3961 100644 --- a/crates/conformance/src/effect.rs +++ b/crates/conformance/src/effect.rs @@ -9,17 +9,16 @@ use std::{ use crate::prng::SplitMix64; use effect_contract::{ - AutomationSpanKind, BankProcessReport, BankWidth, CompositionBound, EffectDescriptor, EffectId, - EffectPrepareError, EffectProcessBlock, EffectQuality, LatencySamples, LinkMode, LinkModeSet, - NativeEffectFactory, NativeEffectRegistry, NodeTailBound, ParameterChannel, - ParameterChannelPolicy, ParameterDescriptor, ParameterDomain, ParameterId, ParameterMapping, - ParameterUnit, PortDescriptor, PortId, PortLayout, PortRole, PrepareEffectBankRequest, - PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, PreparedBankMetadata, - PreparedEffect, PreparedEffectBank, PreparedEffectMetadata, PreparedNativeEffect, - PreparedNativeEffectBank, PreparedPorts, PreparedSidechainPort, ProcessReport, - QualityDescriptor, ResetKind, RestBound, SmoothingRule, StatePayloadError, StatePayloadInput, - StatePayloadOutput, StatePayloadSizes, TailSamples, default_initial_values, - expected_prepared_metadata, valid_runtime_span, + BankProcessReport, BankWidth, CompositionBound, EffectDescriptor, EffectId, EffectPrepareError, + EffectProcessBlock, EffectQuality, LatencySamples, LinkMode, LinkModeSet, NativeEffectFactory, + NativeEffectRegistry, NodeTailBound, ParameterChannel, ParameterChannelPolicy, + ParameterDescriptor, ParameterDomain, ParameterId, ParameterMapping, ParameterUnit, + PortDescriptor, PortId, PortLayout, PortRole, PrepareEffectBankRequest, PrepareEffectLimits, + PrepareEffectRequest, PreparedAutomationSpan, PreparedBankMetadata, PreparedEffect, + PreparedEffectBank, PreparedEffectMetadata, PreparedNativeEffect, PreparedNativeEffectBank, + PreparedPorts, PreparedSidechainPort, ProcessReport, QualityDescriptor, ResetKind, RestBound, + SmoothingRule, StatePayloadError, StatePayloadInput, StatePayloadOutput, StatePayloadSizes, + TailSamples, default_initial_values, expected_prepared_metadata, valid_runtime_span, }; use engine::{LAUNCH_SAMPLE_RATES, realtime::audit}; @@ -431,9 +430,7 @@ impl PreparedNativeEffect for DualAccumulatorDelay { ParameterChannel::Both => &[0, 1], }; for &lane in lanes { - match span.kind { - AutomationSpanKind::Point => self.gain[lane] = span.start_value, - } + self.gain[lane] = span.value; } } for frame in 0..block.left.len() { @@ -1114,13 +1111,10 @@ pub fn run_effect_conformance( lane_isolation_probe(factory, request, expected, config, tier); } let malformed = [PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: u32::MAX, - start_sample: config.quantum as u64, - end_sample: config.quantum as u64, - start_value: 1.0, - end_value: 1.0, + sample: config.quantum as u64, + value: 1.0, }]; let mut malformed_left = [0.0]; let mut malformed_right = [0.0]; diff --git a/crates/conformance/src/ramp_endpoint.rs b/crates/conformance/src/ramp_endpoint.rs index aa31b2308..0cee7e1d4 100644 --- a/crates/conformance/src/ramp_endpoint.rs +++ b/crates/conformance/src/ramp_endpoint.rs @@ -26,12 +26,11 @@ //! admitted the factory (issue #1462 D1). use effect_contract::{ - AutomationSpanKind, BankWidth, EffectBankProcessBlock, EffectId, EffectProcessBlock, - InitialParameterValue, LinkMode, NativeEffectFactory, NativeEffectRegistry, ParameterChannel, - ParameterChannelPolicy, PortRole, PrepareEffectBankRequest, PrepareEffectLimits, - PrepareEffectRequest, PreparedAutomationSpan, PreparedEffect, PreparedNativeEffect, - PreparedPorts, PreparedSidechainPort, RegisteredTailBound, StatePayloadOutput, - default_initial_values, + BankWidth, EffectBankProcessBlock, EffectId, EffectProcessBlock, InitialParameterValue, + LinkMode, NativeEffectFactory, NativeEffectRegistry, ParameterChannel, ParameterChannelPolicy, + PortRole, PrepareEffectBankRequest, PrepareEffectLimits, PrepareEffectRequest, + PreparedAutomationSpan, PreparedEffect, PreparedNativeEffect, PreparedPorts, + PreparedSidechainPort, RegisteredTailBound, StatePayloadOutput, default_initial_values, }; use crate::randomized::admit; @@ -213,13 +212,10 @@ impl Admitted { channels .iter() .map(|&channel| PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: parameter, - start_sample: 0, - end_sample: 0, - start_value: value, - end_value: value, + sample: 0, + value, }) .collect() } diff --git a/crates/conformance/src/randomized.rs b/crates/conformance/src/randomized.rs index 0acb20285..81ab53bea 100644 --- a/crates/conformance/src/randomized.rs +++ b/crates/conformance/src/randomized.rs @@ -40,9 +40,9 @@ use dsp_reference::randomized::{Draw, Profile, first_difference, run_seeds, same_word}; use effect_contract::{ - AutomationRate, AutomationSpanKind, BankProcessReport, BankWidth, EffectBankProcessBlock, - EffectDescriptor, EffectId, EffectProcessBlock, EffectTargetRequest, InitialParameterValue, - LinkMode, NativeEffectFactory, NativeEffectRegistry, NativeEffectTargetPreparation, + AutomationRate, BankProcessReport, BankWidth, EffectBankProcessBlock, EffectDescriptor, + EffectId, EffectProcessBlock, EffectTargetRequest, InitialParameterValue, LinkMode, + NativeEffectFactory, NativeEffectRegistry, NativeEffectTargetPreparation, PREPARED_EFFECT_TARGET_WORDS, ParameterChannel, ParameterChannelPolicy, ParameterDescriptor, ParameterDomain, PortRole, PrepareEffectBankRequest, PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, PreparedEffect, PreparedEffectBank, PreparedEffectMetadata, @@ -1347,7 +1347,7 @@ fn run_width( for (lane, until) in in_flight_until.iter_mut().enumerate() { if scalar_reports[lane].invalid_spans == 0 { for span in &lane_spans[lane] { - *until = (*until).max(span.end_sample.saturating_add(IN_FLIGHT_SAMPLES)); + *until = (*until).max(span.sample.saturating_add(IN_FLIGHT_SAMPLES)); } } } @@ -1881,12 +1881,11 @@ fn draw_spans( // 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| { - span.parameter_index == index as u32 && span.start_sample == start + span.parameter_index == index as u32 && span.sample == start }) { continue; } - let start_value = draw_value(draw, parameter); - let end_value = start_value; + let value = draw_value(draw, parameter); let channels: &[ParameterChannel] = match parameter.channel_policy { ParameterChannelPolicy::Shared => &[ParameterChannel::Both], ParameterChannelPolicy::PerLane if mono => { @@ -1902,33 +1901,30 @@ fn draw_spans( }; for channel in channels { spans.push(PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: *channel, parameter_index: index as u32, - start_sample: start, - end_sample: start, - start_value, - end_value, + sample: start, + value, }); } } } - spans.sort_by_key(|span| (span.start_sample, span.parameter_index, span.channel)); - // Per (parameter, channel), a span starts at or after the previous one's end, and one that - // starts exactly there continues its value. Twins share a history, so they stay or go together. + spans.sort_by_key(|span| (span.sample, span.parameter_index, span.channel)); + // Per (parameter, channel), a span is at or after the previous one's sample, and one at + // exactly that sample repeats its value. Twins share a history, so they stay or go together. let mut last: Vec<((u32, ParameterChannel), u64, u32)> = Vec::new(); spans.retain(|span| { let key = (span.parameter_index, span.channel); if let Some(entry) = last.iter_mut().find(|entry| entry.0 == key) { - if span.start_sample < entry.1 - || (span.start_sample == entry.1 && canonical_bits(span.start_value) != entry.2) + if span.sample < entry.1 + || (span.sample == entry.1 && canonical_bits(span.value) != entry.2) { return false; } - entry.1 = span.end_sample; - entry.2 = canonical_bits(span.end_value); + entry.1 = span.sample; + entry.2 = canonical_bits(span.value); } else { - last.push((key, span.end_sample, canonical_bits(span.end_value))); + last.push((key, span.sample, canonical_bits(span.value))); } true }); @@ -1944,17 +1940,14 @@ 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: AutomationSpanKind::Point, channel: draw.pick(&[ ParameterChannel::Left, ParameterChannel::Right, ParameterChannel::Both, ]), parameter_index: parameter, - start_sample: first + frames as u64 - 1, - end_sample: first + frames as u64 - 1, - start_value: value, - end_value: value, + sample: first + frames as u64 - 1, + value, }); } spans @@ -1983,7 +1976,7 @@ fn render_scalar( let pieces = chunk.map(|_| { spans .iter() - .filter(|span| span.start_sample >= from && span.start_sample < to) + .filter(|span| span.sample >= from && span.sample < to) .copied() .collect::>() }); @@ -2880,13 +2873,10 @@ fn edge_ramp_restore_violations(factory: Box) -> Vec = channels .iter() .map(|channel| PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: *channel, parameter_index: index as u32, - start_sample: 0, - end_sample: 0, - start_value: edge, - end_value: edge, + sample: 0, + value: edge, }) .collect(); for sample in 0..u64::from(samples) { diff --git a/crates/conformance/tests/effect_contract.rs b/crates/conformance/tests/effect_contract.rs index 8384f1249..9b7aa7f7f 100644 --- a/crates/conformance/tests/effect_contract.rs +++ b/crates/conformance/tests/effect_contract.rs @@ -18,13 +18,13 @@ use conformance::{ admit, run_effect_conformance, }; use effect_contract::{ - AutomationSpanKind, BankWidth, DescriptorDiagnosticCode, DescriptorError, EffectDescriptor, - EffectQuality, InitialParameterValue, LinkMode, NativeEffectFactory, ParameterChannel, - ParameterId, ParameterMapping, ParameterSmoother, PrepareEffectBankRequest, - PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, PreparedPorts, - PreparedSidechainPort, QualityDescriptor, RegisteredTailBound, SmoothingRule, - expected_prepared_metadata, inverse_map_normalized, inverse_map_stepped_normalized, - map_normalized, map_stepped_normalized, validate_automation_block, validate_descriptor, + BankWidth, DescriptorDiagnosticCode, DescriptorError, EffectDescriptor, EffectQuality, + InitialParameterValue, LinkMode, NativeEffectFactory, ParameterChannel, ParameterId, + ParameterMapping, ParameterSmoother, PrepareEffectBankRequest, PrepareEffectLimits, + PrepareEffectRequest, PreparedAutomationSpan, PreparedPorts, PreparedSidechainPort, + QualityDescriptor, RegisteredTailBound, SmoothingRule, 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). @@ -234,7 +234,7 @@ fn every_faulty_mock_is_detected() { fn ten_thousand_descriptor_and_span_mutations_reject_without_panic() { for seed in 0..10_000_u32 { let mut descriptor: EffectDescriptor = DUAL_ACCUMULATOR_DELAY_DESCRIPTOR; - match seed % 5 { + match seed % 4 { 0 => descriptor.contract_major = 2, 1 => descriptor.state_layout_version = 0, 2 => descriptor.display_name = "", @@ -292,20 +292,16 @@ fn ten_thousand_descriptor_and_span_mutations_reject_without_panic() { .unwrap(); for seed in 0..10_000_u32 { let mut span = PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 0, - start_sample: 0, - end_sample: 0, - start_value: 1.0, - end_value: 1.0, + sample: 0, + value: 1.0, }; - match seed % 5 { + match seed % 4 { 0 => span.parameter_index = u32::MAX, 1 => span.channel = ParameterChannel::Both, - 2 => span.start_sample = 128, - 3 => span.end_value = f32::NAN, - _ => span.end_sample = 1, + 2 => span.sample = 128, + _ => span.value = f32::NAN, } let result = std::panic::catch_unwind(|| validate_automation_block(&[span], metadata, 0, 128)); diff --git a/crates/delay/src/corpus.rs b/crates/delay/src/corpus.rs index 4a489565b..82b2864ae 100644 --- a/crates/delay/src/corpus.rs +++ b/crates/delay/src/corpus.rs @@ -24,9 +24,9 @@ //! input, so every sample is finite; the test module checks that rather than assuming it. use effect_contract::{ - AutomationSpanKind, EffectProcessBlock, EffectQuality, InitialParameterValue, LinkMode, - NativeEffectFactory, NativeEffectRegistry, ParameterChannel, PrepareEffectLimits, - PrepareEffectRequest, PreparedAutomationSpan, PreparedPorts, PreparedSidechainPort, + EffectProcessBlock, EffectQuality, InitialParameterValue, LinkMode, NativeEffectFactory, + NativeEffectRegistry, ParameterChannel, PrepareEffectLimits, PrepareEffectRequest, + PreparedAutomationSpan, PreparedPorts, PreparedSidechainPort, }; use crate::{DELAY_DESCRIPTOR, DELAY_PARAMETERS, DelayFactory}; @@ -127,13 +127,10 @@ fn event(first_sample: u64) -> [PreparedAutomationSpan; 5] { let mix = [1.0_f32, 0.0, 0.25, 0.6]; let cross = [0.5_f32, 1.0, 0.0, 0.75]; let point = |parameter_index: u32, channel, value| PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index, - start_sample: first_sample, - end_sample: first_sample, - start_value: value, - end_value: value, + sample: first_sample, + value, }; [ point(0, ParameterChannel::Left, delay[step]), diff --git a/crates/delay/src/lib.rs b/crates/delay/src/lib.rs index 3135161ff..def3bd7db 100644 --- a/crates/delay/src/lib.rs +++ b/crates/delay/src/lib.rs @@ -37,13 +37,13 @@ #![allow(missing_docs)] use effect_contract::{ - AutomationRate, AutomationSpanKind, EffectDescriptor, EffectPrepareError, EffectProcessBlock, - EffectQuality, InitialParameterValue, LatencySamples, LinkModeSet, NativeEffectFactory, - ParameterChannel, ParameterChannelPolicy, ParameterDescriptor, ParameterDomain, ParameterId, - ParameterMapping, ParameterUnit, PortDescriptor, PortId, PortLayout, PortRole, - PrepareEffectBankRequest, PrepareEffectRequest, PreparedAutomationSpan, PreparedEffect, - PreparedEffectMetadata, PreparedNativeEffect, ProcessReport, ResetKind, SmoothingRule, - StatePayloadError, StatePayloadInput, StatePayloadOutput, StatePayloadSizes, TailSamples, + AutomationRate, EffectDescriptor, EffectPrepareError, EffectProcessBlock, EffectQuality, + InitialParameterValue, LatencySamples, LinkModeSet, NativeEffectFactory, ParameterChannel, + ParameterChannelPolicy, ParameterDescriptor, ParameterDomain, ParameterId, ParameterMapping, + ParameterUnit, PortDescriptor, PortId, PortLayout, PortRole, PrepareEffectBankRequest, + PrepareEffectRequest, PreparedAutomationSpan, PreparedEffect, PreparedEffectMetadata, + PreparedNativeEffect, ProcessReport, ResetKind, SmoothingRule, StatePayloadError, + StatePayloadInput, StatePayloadOutput, StatePayloadSizes, TailSamples, expected_prepared_metadata, }; use effect_runtime::bank::check_block; @@ -1514,24 +1514,21 @@ fn apply_automation( let accepted = match slot { Some(slot) => { span_index < automation_capacity as usize - && span.kind == AutomationSpanKind::Point - && span.start_sample == first_sample - && span.end_sample == first_sample - && span.start_value.to_bits() == span.end_value.to_bits() - && parameter_value_valid(&PARAMETER_SPECS[parameter_index], span.start_value) + && span.sample == first_sample + && parameter_value_valid(&PARAMETER_SPECS[parameter_index], span.value) && last_slot.is_none_or(|previous| slot > previous) && pending[slot].is_none() // The delay time is the one parameter whose domain is not the whole story: it // has to land on a legal integer tap as well. && (parameter_index != 0 - || delay_samples(span.start_value, sample_rate, maximum_delay).is_some()) + || delay_samples(span.value, sample_rate, maximum_delay).is_some()) } None => false, }; match slot.filter(|_| accepted) { Some(slot) => { last_slot = Some(slot); - pending[slot] = Some(normalize_zero(span.start_value)); + pending[slot] = Some(normalize_zero(span.value)); } None => report.invalid_spans = report.invalid_spans.saturating_add(1), } @@ -1814,13 +1811,10 @@ mod tests { value: f32, ) -> PreparedAutomationSpan { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index, - start_sample: first_sample, - end_sample: first_sample, - start_value: value, - end_value: value, + sample: first_sample, + value, } } @@ -2694,19 +2688,9 @@ mod tests { point(9, ParameterChannel::Left, 0, 0.5), // Outside the domain. point(4, ParameterChannel::Both, 0, 2.0), - // A point whose end sample differs from its start sample. - PreparedAutomationSpan { - kind: AutomationSpanKind::Point, - channel: ParameterChannel::Both, - parameter_index: 4, - start_sample: 0, - end_sample: 1, - start_value: 0.5, - end_value: 0.5, - }, ]; let report = process_zeros(&mut effect, 4, 0, &spans); - assert_eq!(report.invalid_spans, 5); + assert_eq!(report.invalid_spans, 4); assert_eq!(effect.left.ramps[2].target.to_bits(), 0.5_f32.to_bits()); assert_eq!(effect.left.ramps[0].current.to_bits(), 0.35_f32.to_bits()); assert_eq!(effect.cross.target.to_bits(), 0.0_f32.to_bits()); diff --git a/crates/effect-compiler/tests/native_session.rs b/crates/effect-compiler/tests/native_session.rs index f39d277e5..7e33df4a8 100644 --- a/crates/effect-compiler/tests/native_session.rs +++ b/crates/effect-compiler/tests/native_session.rs @@ -705,13 +705,10 @@ fn every_launch_effect_is_prepared_with_a_window_for_all_its_live_cells() { .expect("a live lane"); let mut window = vec![ PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: effect_contract::ParameterChannel::Both, parameter_index: u32::MAX, - start_sample: 0, - end_sample: 0, - start_value: 0.0, - end_value: 0.0, + sample: 0, + value: 0.0, }; capacity ]; diff --git a/crates/effect-contract/src/lib.rs b/crates/effect-contract/src/lib.rs index ad98a3524..93a073cff 100644 --- a/crates/effect-contract/src/lib.rs +++ b/crates/effect-contract/src/lib.rs @@ -420,11 +420,6 @@ 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}); -// 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}); scalar_enum!(StepUnit {Absolute=1,Cents=2,Ratio=3,Index=4}); @@ -1646,15 +1641,18 @@ impl BankProcessReport { } } } +/// One value for one parameter channel at one sample: the whole of an effect's automation input. +/// +/// Every span is a point. The span kinds `Step`, `Linear` and `Exponential` (raw values 2 to 4 of +/// the retired `AutomationSpanKind`) were retired in #1636 because no producer stages them and no +/// effect accepts them, and #1686 removed the kind and the end of a span, which a point only +/// repeats from its start. Those raw values are never reallocated. #[derive(Clone, Copy, Debug, PartialEq)] pub struct PreparedAutomationSpan { - pub kind: AutomationSpanKind, pub channel: ParameterChannel, pub parameter_index: u32, - pub start_sample: u64, - pub end_sample: u64, - pub start_value: f32, - pub end_value: f32, + pub sample: u64, + pub value: f32, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ProcessBlockError { @@ -1812,25 +1810,14 @@ pub fn valid_runtime_span( let Some(p) = descriptor.parameters.get(s.parameter_index as usize) else { return false; }; - if !s.start_value.is_finite() - || !s.end_value.is_finite() - || s.start_sample < first - || s.start_sample >= first.saturating_add(frames as u64) - || !parameter_value_valid(p, s.start_value) - || !parameter_value_valid(p, s.end_value) - || (p.channel_policy == ParameterChannelPolicy::Shared + s.value.is_finite() + && s.sample >= first + && s.sample < first.saturating_add(frames as u64) + && parameter_value_valid(p, s.value) + && !(p.channel_policy == ParameterChannelPolicy::Shared && s.channel != ParameterChannel::Both) - || (p.channel_policy == ParameterChannelPolicy::PerLane + && !(p.channel_policy == ParameterChannelPolicy::PerLane && s.channel == ParameterChannel::Both) - { - return false; - } - match s.kind { - AutomationSpanKind::Point => { - s.start_sample == s.end_sample - && canonical_bits(s.start_value) == canonical_bits(s.end_value) - } - } } /// Validate the compiler-owned canonical ordering and non-overlap rules for one delivered block. @@ -1860,22 +1847,21 @@ pub fn validate_automation_block( if !valid_runtime_span(span, metadata.descriptor, first_sample, frames) { return Err(ProcessBlockError::Automation); } - let sort_key = (span.start_sample, span.parameter_index, span.channel); + let sort_key = (span.sample, span.parameter_index, span.channel); if prior_sort_key.is_some_and(|prior| sort_key < prior) { return Err(ProcessBlockError::Automation); } if let Some(previous) = spans[..span_index].iter().rev().find(|previous| { previous.parameter_index == span.parameter_index && previous.channel == span.channel - }) && (span.start_sample < previous.end_sample - || (span.start_sample == previous.end_sample - && canonical_bits(span.start_value) != canonical_bits(previous.end_value))) + }) && (span.sample < previous.sample + || (span.sample == previous.sample + && canonical_bits(span.value) != canonical_bits(previous.value))) { return Err(ProcessBlockError::Automation); } let parameter = &metadata.descriptor.parameters[span.parameter_index as usize]; if parameter.automation_rate == AutomationRate::None - || (parameter.automation_rate == AutomationRate::Block - && span.start_sample != first_sample) + || (parameter.automation_rate == AutomationRate::Block && span.sample != first_sample) { return Err(ProcessBlockError::Automation); } @@ -3211,25 +3197,16 @@ mod bank_width_tests { #[cfg(test)] mod automation_smoothing_validity_tests { - use super::{ - AutomationRate, AutomationSpanKind, SmoothingRule, parameter_automation_smoothing_valid, - }; + use super::{AutomationRate, 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. + /// Red if a retired automation rate parses again (#1636): a descriptor could declare a rate + /// the engine never delivers. #[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] diff --git a/crates/effect-contract/src/live.rs b/crates/effect-contract/src/live.rs index 2326b9c77..45821889e 100644 --- a/crates/effect-contract/src/live.rs +++ b/crates/effect-contract/src/live.rs @@ -44,9 +44,9 @@ //! //! A drained value takes effect at the **first sample of the next rendered block**, exactly as //! #137's matrix retarget does: [`EffectControlLane::stage`] runs at the top of the block, before -//! a single sample is touched, and emits `AutomationSpanKind::Point` spans whose `start_sample` and -//! `end_sample` are that block's `first_sample`. Every launch effect accepts precisely that shape -//! (a `Point` at `first_sample` with bit-identical endpoints) and rejects anything else into +//! a single sample is touched, and emits [`PreparedAutomationSpan`]s whose `sample` is that block's +//! `first_sample`. Every launch effect accepts precisely that shape +//! (a span at `first_sample`) and rejects anything else into //! `ProcessReport::invalid_spans`, so the block boundary is proven by the effect contract rather //! than asserted by the host. //! @@ -86,9 +86,9 @@ use lane::kernels::{ use std::sync::Arc; use crate::{ - AutomationRate, AutomationSpanKind, ChannelSymmetryWitness, EffectDescriptor, - ObservationDescriptor, ObservationFold, ObservationSample, PREPARED_EFFECT_TARGET_WORDS, - ParameterChannel, ParameterChannelPolicy, PreparedAutomationSpan, PreparedEffectTarget, + AutomationRate, ChannelSymmetryWitness, EffectDescriptor, ObservationDescriptor, + ObservationFold, ObservationSample, PREPARED_EFFECT_TARGET_WORDS, ParameterChannel, + ParameterChannelPolicy, PreparedAutomationSpan, PreparedEffectTarget, }; /// One admitted live control event for one prepared effect instance. @@ -1314,13 +1314,10 @@ impl EffectControlLane { }; let value = f32::from_bits(bits); staging[staged] = PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index, - start_sample: first_sample, - end_sample: first_sample, - start_value: value, - end_value: value, + sample: first_sample, + value, }; staged += 1; // The one hook: a record kind cannot reach the render state without declaring diff --git a/crates/effect-contract/tests/live_control.rs b/crates/effect-contract/tests/live_control.rs index ad232f978..63cef0fb2 100644 --- a/crates/effect-contract/tests/live_control.rs +++ b/crates/effect-contract/tests/live_control.rs @@ -189,24 +189,13 @@ fn a_drain_emits_the_contract_canonical_span_order() { "spans must leave the drain in (parameter_index, channel) order" ); for span in &rig.staging[..3] { - assert_eq!(span.kind, effect_contract::AutomationSpanKind::Point); - assert_eq!(span.start_sample, 0); - assert_eq!(span.end_sample, 0); - assert_eq!( - span.start_value.to_bits(), - span.end_value.to_bits(), - "a Point span's endpoints are bit-identical, which is what every effect checks" - ); + assert_eq!(span.sample, 0); } let staged = rig.stage(); assert_eq!(staged.staged, 0); rig.commit(&[left(3, 0.75)]).expect("a cell"); rig.stage(); - assert_eq!( - rig.staging[0].start_sample, - 128 * 2, - "the second block's sample" - ); + assert_eq!(rig.staging[0].sample, 128 * 2, "the second block's sample"); } /// A repeated write collapses last-wins: one span per cell, the newest value, and each replaced @@ -222,7 +211,7 @@ fn a_repeated_write_collapses_last_wins_and_is_counted() { assert_eq!(staged.staged, 2); assert_eq!(rig.staging[0].parameter_index, 0); assert_eq!(rig.staging[1].parameter_index, 2); - assert_eq!(rig.staging[1].start_value, 7.0, "the last write wins"); + assert_eq!(rig.staging[1].value, 7.0, "the last write wins"); assert_eq!(rig.superseded.get(), 1); } @@ -287,7 +276,7 @@ fn a_predecessor_without_slots_hands_its_unread_write_to_an_installed_successor( assert!(successor.lane.carry_from(&predecessor.lane)); let staged = successor.stage(); assert_eq!(staged.staged, 1); - assert_eq!(successor.staging[0].start_value, 0.25); + assert_eq!(successor.staging[0].value, 0.25); assert_eq!(successor.superseded.get(), 0); assert_eq!(successor.stage().staged, 0, "the slot resolves once"); } @@ -415,11 +404,11 @@ fn a_value_above_the_snapshot_waits_and_is_applied_once() { // The first block reads under the older snapshot: the first value, and the newer one waits. let staged = rig.stage_under(covered); assert_eq!(staged.staged, 1); - assert_eq!(rig.staging[0].start_value, 1.0); + assert_eq!(rig.staging[0].value, 1.0); rig.pin.publish(&rig.gate, revision); let staged = rig.stage(); assert_eq!(staged.staged, 1); - assert_eq!(rig.staging[0].start_value, 2.0); + assert_eq!(rig.staging[0].value, 2.0); assert_eq!(rig.stage().staged, 0, "applied once"); assert_eq!( rig.superseded.get(), diff --git a/crates/effect-contract/tests/live_window.rs b/crates/effect-contract/tests/live_window.rs index 228447e28..db67cfa7d 100644 --- a/crates/effect-contract/tests/live_window.rs +++ b/crates/effect-contract/tests/live_window.rs @@ -272,7 +272,7 @@ fn one_block_applies_every_live_cell_of_one_transaction() { .iter() .find(|span| span.parameter_index == parameter_index && span.channel == channel) .expect("a span for every record"); - assert_eq!(span.start_value.to_bits(), value.to_bits()); + assert_eq!(span.value.to_bits(), value.to_bits()); } assert_eq!(rig.superseded.get(), 0); assert_eq!(rig.stage().staged, 0, "and none is left for a later block"); diff --git a/crates/effect-contract/tests/support/lane_rig.rs b/crates/effect-contract/tests/support/lane_rig.rs index 40e533e6a..127890b62 100644 --- a/crates/effect-contract/tests/support/lane_rig.rs +++ b/crates/effect-contract/tests/support/lane_rig.rs @@ -4,10 +4,9 @@ #![allow(dead_code)] use effect_contract::{ - AutomationSpanKind, BypassSwitch, ChannelSymmetryWitness, EffectCellWriter, EffectControlLane, - EffectControlRecord, EffectLaneShape, EffectLaneSpec, EffectWriteRefusal, - PREPARED_EFFECT_TARGET_WORDS, ParameterChannel, PreparedAutomationSpan, PreparedEffectTarget, - Staged, effect_control_lane, + BypassSwitch, ChannelSymmetryWitness, EffectCellWriter, EffectControlLane, EffectControlRecord, + EffectLaneShape, EffectLaneSpec, EffectWriteRefusal, PREPARED_EFFECT_TARGET_WORDS, + ParameterChannel, PreparedAutomationSpan, PreparedEffectTarget, Staged, effect_control_lane, }; use engine::realtime::{GatePin, LiveSnapshot, LiveValuesSuperseded, RevisionGate}; @@ -16,13 +15,10 @@ pub(crate) const UNBYPASSED: u8 = ChannelSymmetryWitness::UNBYPASSED; pub(crate) fn idle() -> PreparedAutomationSpan { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Both, parameter_index: u32::MAX, - start_sample: 0, - end_sample: 0, - start_value: 0.0, - end_value: 0.0, + sample: 0, + value: 0.0, } } @@ -156,13 +152,7 @@ impl Rig { pub(crate) fn staged_spans(&self, staged: Staged) -> Vec<(u32, ParameterChannel, u32)> { self.staging[..staged.staged] .iter() - .map(|span| { - ( - span.parameter_index, - span.channel, - span.start_value.to_bits(), - ) - }) + .map(|span| (span.parameter_index, span.channel, span.value.to_bits())) .collect() } } diff --git a/crates/gate-expander/src/lib.rs b/crates/gate-expander/src/lib.rs index 482e59cf9..7307e6ff5 100644 --- a/crates/gate-expander/src/lib.rs +++ b/crates/gate-expander/src/lib.rs @@ -24,17 +24,17 @@ pub mod kernel; use effect_contract::control::{PreparedReason, RebuildReason}; use effect_contract::{ - AutomationRate, AutomationSpanKind, BankProcessReport, BankWidth, EffectBankProcessBlock, - EffectDescriptor, EffectPrepareError, EffectProcessBlock, EffectQuality, InitialParameterValue, - LatencySamples, LinkMode, LinkModeSet, NativeEffectFactory, ObservationCadence, - ObservationChannels, ObservationCost, ObservationDescriptor, ObservationFold, ObservationKind, - ObservationSample, ObservationTapId, ParameterChannel, ParameterChannelPolicy, - ParameterDescriptor, ParameterDomain, ParameterId, ParameterMapping, ParameterUnit, - PortDescriptor, PortId, PortLayout, PortRole, PrepareEffectBankRequest, PrepareEffectRequest, - PreparedAutomationSpan, PreparedBankMetadata, PreparedEffect, PreparedEffectBank, - PreparedEffectMetadata, PreparedNativeEffect, PreparedNativeEffectBank, PreparedSidechainPort, - ProcessReport, ResetKind, SmoothingRule, StatePayloadError, StatePayloadInput, - StatePayloadOutput, StatePayloadSizes, TailSamples, expected_prepared_metadata, + AutomationRate, BankProcessReport, BankWidth, EffectBankProcessBlock, EffectDescriptor, + EffectPrepareError, EffectProcessBlock, EffectQuality, InitialParameterValue, LatencySamples, + LinkMode, LinkModeSet, NativeEffectFactory, ObservationCadence, ObservationChannels, + ObservationCost, ObservationDescriptor, ObservationFold, ObservationKind, ObservationSample, + ObservationTapId, ParameterChannel, ParameterChannelPolicy, ParameterDescriptor, + ParameterDomain, ParameterId, ParameterMapping, ParameterUnit, PortDescriptor, PortId, + PortLayout, PortRole, PrepareEffectBankRequest, PrepareEffectRequest, PreparedAutomationSpan, + PreparedBankMetadata, PreparedEffect, PreparedEffectBank, PreparedEffectMetadata, + PreparedNativeEffect, PreparedNativeEffectBank, PreparedSidechainPort, ProcessReport, + ResetKind, SmoothingRule, StatePayloadError, StatePayloadInput, StatePayloadOutput, + StatePayloadSizes, TailSamples, expected_prepared_metadata, }; use effect_runtime::bank::{check_block, nonfinite_lane_mask}; use effect_runtime::envelope::attack_release_coefficient; @@ -609,11 +609,8 @@ impl PreparedGate { }; let valid = span_index < self.automation_capacity as usize && parameter_index < RAMP_COUNT - && span.kind == AutomationSpanKind::Point - && span.start_sample == first_sample - && span.end_sample == first_sample - && span.start_value.to_bits() == span.end_value.to_bits() - && parameter_value_valid(&GATE_SPECS[parameter_index], span.start_value) + && span.sample == first_sample + && parameter_value_valid(&GATE_SPECS[parameter_index], span.value) && last_order.is_none_or(|previous| order > previous) && pending[channel][parameter_index].is_none(); if !valid { @@ -621,7 +618,7 @@ impl PreparedGate { continue; } last_order = Some(order); - pending[channel][parameter_index] = Some(normalize_zero(span.start_value)); + pending[channel][parameter_index] = Some(normalize_zero(span.value)); } for (channel, targets) in pending.iter().enumerate() { for (index, target) in targets.iter().enumerate() { diff --git a/crates/gate-expander/src/padding_tests.rs b/crates/gate-expander/src/padding_tests.rs index d2a9a1021..8ea59e150 100644 --- a/crates/gate-expander/src/padding_tests.rs +++ b/crates/gate-expander/src/padding_tests.rs @@ -224,13 +224,10 @@ impl Draw { } let value = self.value(parameter); spans.push(PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: index as u32, - start_sample: first_sample, - end_sample: first_sample, - start_value: value, - end_value: value, + sample: first_sample, + value, }); } } @@ -374,13 +371,10 @@ fn render(padded: &Padded<'_>, blocks: &[Block], context: &str) -> Vec> spans.extend( [ParameterChannel::Both, ParameterChannel::Left].map(|channel| { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: 0, - start_sample: first_sample, - end_sample: first_sample, - start_value: -30.0, - end_value: -30.0, + sample: first_sample, + value: -30.0, } }), ); diff --git a/crates/gate-expander/tests/support/mod.rs b/crates/gate-expander/tests/support/mod.rs index 4bff11ab1..85ab25391 100644 --- a/crates/gate-expander/tests/support/mod.rs +++ b/crates/gate-expander/tests/support/mod.rs @@ -3,10 +3,10 @@ use lane::Backend; use effect_contract::{ - AutomationSpanKind, BankWidth, EffectProcessBlock, EffectQuality, InitialParameterValue, - LinkMode, NativeEffectFactory, ParameterChannel, PortId, PrepareEffectBankRequest, - PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, PreparedEffect, - PreparedEffectBank, PreparedPorts, PreparedSidechainPort, ProcessReport, StatePayloadOutput, + BankWidth, EffectProcessBlock, EffectQuality, InitialParameterValue, LinkMode, + NativeEffectFactory, ParameterChannel, PortId, PrepareEffectBankRequest, PrepareEffectLimits, + PrepareEffectRequest, PreparedAutomationSpan, PreparedEffect, PreparedEffectBank, + PreparedPorts, PreparedSidechainPort, ProcessReport, StatePayloadOutput, }; use gate_expander::{GATE_EXPANDER_DESCRIPTOR, GATE_EXPANDER_PARAMETERS, GateExpanderFactory}; @@ -331,17 +331,14 @@ pub fn retarget_spans(first_sample: u64) -> [PreparedAutomationSpan; 8] { targets[parameter_index].1 }; PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: if left { ParameterChannel::Left } else { ParameterChannel::Right }, parameter_index: parameter_index as u32, - start_sample: first_sample, - end_sample: first_sample, - start_value: value, - end_value: value, + sample: first_sample, + value, } }) } diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index c57a6438a..22d3bff20 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -8230,8 +8230,8 @@ mod tests { fn reset(&mut self, _kind: ResetKind) {} fn process(&mut self, block: EffectProcessBlock<'_>) -> ProcessReport { let mut report = ProcessReport::default(); - // The same strictness every launch effect applies: a `Point` at `first_sample` with - // bit-identical endpoints, addressed to one lane, inside the declared capacity. + // The same strictness every launch effect applies: a span at `first_sample`, + // addressed to one lane, inside the declared capacity. for (index, span) in block.automation.iter().enumerate() { let lane = match span.channel { ParameterChannel::Left => 0_usize, @@ -8243,15 +8243,12 @@ mod tests { }; let valid = index < self.automation_capacity as usize && span.parameter_index == 0 - && span.kind == effect_contract::AutomationSpanKind::Point - && span.start_sample == block.first_sample - && span.end_sample == block.first_sample - && span.start_value.to_bits() == span.end_value.to_bits(); + && span.sample == block.first_sample; if !valid { report.invalid_spans += 1; continue; } - self.gain[lane] = span.start_value; + self.gain[lane] = span.value; } self.invalid = self.invalid.saturating_add(report.invalid_spans); for frame in 0..block.left.len() { diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index c99a42d6f..803d1cb4e 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -1439,13 +1439,10 @@ impl LiveControlEffect { let latency = usize::try_from(effect.metadata.latency.0).unwrap_or(usize::MAX); let spans: Box<[PreparedAutomationSpan]> = vec![ PreparedAutomationSpan { - kind: effect_contract::AutomationSpanKind::Point, channel: effect_contract::ParameterChannel::Both, parameter_index: 0, - start_sample: 0, - end_sample: 0, - start_value: 0.0, - end_value: 0.0, + sample: 0, + value: 0.0, }; capacity ] diff --git a/crates/host-core/tests/effect_observation.rs b/crates/host-core/tests/effect_observation.rs index 39e79ed7f..713a80da3 100644 --- a/crates/host-core/tests/effect_observation.rs +++ b/crates/host-core/tests/effect_observation.rs @@ -10,10 +10,10 @@ use core::num::{NonZeroU32, NonZeroUsize}; use builtins::MeterTap; use effect_contract::{ - AutomationSpanKind, EffectControlRecord, EffectProcessBlock, EffectQuality, - InitialParameterValue, LinkMode, NativeEffectFactory, ObservationSample, ParameterChannel, - PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, PreparedNativeEffect, - PreparedPorts, PreparedSidechainPort, + EffectControlRecord, EffectProcessBlock, EffectQuality, InitialParameterValue, LinkMode, + NativeEffectFactory, ObservationSample, ParameterChannel, PrepareEffectLimits, + PrepareEffectRequest, PreparedAutomationSpan, PreparedNativeEffect, PreparedPorts, + PreparedSidechainPort, }; use engine::realtime::{PlanarBufferMut, RenderIo, RenderTime}; use host_core::{ @@ -796,13 +796,10 @@ fn scalar_reference_windows( Some((at, value)) if at == block => [ParameterChannel::Left, ParameterChannel::Right] .into_iter() .map(|channel| PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: THRESHOLD_INDEX, - start_sample: first_sample, - end_sample: first_sample, - start_value: value, - end_value: value, + sample: first_sample, + value, }) .collect(), _ => Vec::new(), diff --git a/crates/multiband-compressor/src/corpus.rs b/crates/multiband-compressor/src/corpus.rs index d785bb301..7c7517041 100644 --- a/crates/multiband-compressor/src/corpus.rs +++ b/crates/multiband-compressor/src/corpus.rs @@ -37,10 +37,10 @@ //! look like a numerics bug. use effect_contract::{ - AutomationSpanKind, EffectQuality, InitialParameterValue, LinkMode, NativeEffectFactory, - NativeEffectRegistry, ParameterChannel, PrepareEffectLimits, PrepareEffectRequest, - PreparedAutomationSpan, PreparedPorts, PreparedSidechainPort, ProcessReport, StatePayloadInput, - StatePayloadOutput, expected_prepared_metadata, + EffectQuality, InitialParameterValue, LinkMode, NativeEffectFactory, NativeEffectRegistry, + ParameterChannel, PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, + PreparedPorts, PreparedSidechainPort, ProcessReport, StatePayloadInput, StatePayloadOutput, + expected_prepared_metadata, }; use effect_runtime::envelope::retention_coefficient; use effect_runtime::state_payload::write_f32; @@ -545,13 +545,10 @@ fn dispatch_case(out: &mut [u32]) { continue; } let span = PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: parameter, - start_sample: first_sample, - end_sample: first_sample, - start_value: value, - end_value: value, + sample: first_sample, + value, }; let lane = track - group * W; instance.apply_automation( diff --git a/crates/multiband-compressor/src/lib.rs b/crates/multiband-compressor/src/lib.rs index 22874d743..f0face704 100644 --- a/crates/multiband-compressor/src/lib.rs +++ b/crates/multiband-compressor/src/lib.rs @@ -38,17 +38,17 @@ use effect_contract::control::{PreparedReason, RebuildReason}; use effect_contract::{ - AutomationRate, AutomationSpanKind, BankProcessReport, BankWidth, EffectBankProcessBlock, - EffectDescriptor, EffectPrepareError, EffectProcessBlock, EffectQuality, InitialParameterValue, - LatencySamples, LinkMode, LinkModeSet, NativeEffectFactory, ObservationCadence, - ObservationChannels, ObservationCost, ObservationDescriptor, ObservationFold, ObservationKind, - ObservationSample, ObservationTapId, ParameterChannel, ParameterChannelPolicy, - ParameterDescriptor, ParameterDomain, ParameterId, ParameterMapping, ParameterUnit, - PortDescriptor, PortId, PortLayout, PortRole, PrepareEffectBankRequest, PrepareEffectRequest, - PreparedAutomationSpan, PreparedBankMetadata, PreparedEffect, PreparedEffectBank, - PreparedEffectMetadata, PreparedNativeEffect, PreparedNativeEffectBank, ProcessReport, - ResetKind, SmoothingRule, StatePayloadError, StatePayloadInput, StatePayloadOutput, - StatePayloadSizes, TailSamples, expected_prepared_metadata, + AutomationRate, BankProcessReport, BankWidth, EffectBankProcessBlock, EffectDescriptor, + EffectPrepareError, EffectProcessBlock, EffectQuality, InitialParameterValue, LatencySamples, + LinkMode, LinkModeSet, NativeEffectFactory, ObservationCadence, ObservationChannels, + ObservationCost, ObservationDescriptor, ObservationFold, ObservationKind, ObservationSample, + ObservationTapId, ParameterChannel, ParameterChannelPolicy, ParameterDescriptor, + ParameterDomain, ParameterId, ParameterMapping, ParameterUnit, PortDescriptor, PortId, + PortLayout, PortRole, PrepareEffectBankRequest, PrepareEffectRequest, PreparedAutomationSpan, + PreparedBankMetadata, PreparedEffect, PreparedEffectBank, PreparedEffectMetadata, + PreparedNativeEffect, PreparedNativeEffectBank, ProcessReport, ResetKind, SmoothingRule, + StatePayloadError, StatePayloadInput, StatePayloadOutput, StatePayloadSizes, TailSamples, + expected_prepared_metadata, }; use effect_runtime::bank::{self, NonFiniteReport}; use effect_runtime::dynamics::{GainComputerCoef, gain_delta_db, knee_coefficients}; @@ -1408,7 +1408,7 @@ impl Instance { /// Points one track's ramps at the values a block's automation spans carry. /// - /// The spans are `AutomationSpanKind::Point` events landing on the block's first sample, one + /// The spans are events landing on the block's first sample, one /// per parameter per channel, in `parameter_index * 2 + channel` order. Anything else is /// counted as an invalid span and ignored; nothing partially applies. fn apply_automation( @@ -1440,11 +1440,8 @@ impl Instance { .filter(|value| *value < RAMP_COUNT && parameter < PARAMETER_COUNT); let valid = ramp.is_some() && index < capacity as usize - && span.kind == AutomationSpanKind::Point - && span.start_sample == first_sample - && span.end_sample == first_sample - && span.start_value.to_bits() == span.end_value.to_bits() - && parameter_value_valid(&SPECS[parameter], span.start_value) + && span.sample == first_sample + && parameter_value_valid(&SPECS[parameter], span.value) && order.is_some_and(|value| previous.is_none_or(|earlier| value > earlier)) && ramp.is_some_and(|value| pending[side][value].is_none()); let Some(ramp) = ramp else { @@ -1456,7 +1453,7 @@ impl Instance { continue; } previous = order; - pending[side][ramp] = Some(normalize_zero(span.start_value)); + pending[side][ramp] = Some(normalize_zero(span.value)); } for (side, targets) in pending.iter().enumerate() { for (index, target) in targets.iter().enumerate() { diff --git a/crates/multiband-compressor/src/split.rs b/crates/multiband-compressor/src/split.rs index 2ca43dbd8..eb6f5a58f 100644 --- a/crates/multiband-compressor/src/split.rs +++ b/crates/multiband-compressor/src/split.rs @@ -227,13 +227,10 @@ fn run( .iter() .filter(|event| event.track == track) .map(|event| PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: event.channel, parameter_index: (event.ramp + 1) as u32, - start_sample: (block * frames) as u64, - end_sample: (block * frames) as u64, - start_value: event.value, - end_value: event.value, + sample: (block * frames) as u64, + value: event.value, }) .collect(); let mut report = ProcessReport::default(); @@ -459,13 +456,10 @@ fn a_settled_bank_meets_the_flat_paths_precondition() { // A zero sent to a parameter whose domain contains zero, as `-0.0`: `normalize_zero` is what // keeps it off the flat path's excluded value, so send the bit pattern that would break it. let spans = [PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: (LOW_MAKEUP + 1) as u32, - start_sample: 0, - end_sample: 0, - start_value: -0.0, - end_value: -0.0, + sample: 0, + value: -0.0, }]; instance.apply_automation(0, &spans, 32, 0, &mut report); assert_eq!(report.invalid_spans, 0, "-0.0 is inside the makeup domain"); @@ -580,13 +574,10 @@ fn a_window_lands_on_its_target_on_the_exact_sample() { ); let spans = [PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: (LOW_MAKEUP + 1) as u32, - start_sample: 0, - end_sample: 0, - start_value: TARGET, - end_value: TARGET, + sample: 0, + value: TARGET, }]; let mut report = ProcessReport::default(); instance.apply_automation(0, &spans, 32, 0, &mut report); diff --git a/crates/multiband-compressor/tests/support/mod.rs b/crates/multiband-compressor/tests/support/mod.rs index 1abb79497..f3cf7b766 100644 --- a/crates/multiband-compressor/tests/support/mod.rs +++ b/crates/multiband-compressor/tests/support/mod.rs @@ -10,8 +10,8 @@ pub mod oracle; use lane::Backend; use effect_contract::{ - AutomationSpanKind, BankWidth, EffectProcessBlock, EffectQuality, InitialParameterValue, - LinkMode, NativeEffectFactory, ParameterChannel, PrepareEffectBankRequest, PrepareEffectLimits, + BankWidth, EffectProcessBlock, EffectQuality, InitialParameterValue, LinkMode, + NativeEffectFactory, ParameterChannel, PrepareEffectBankRequest, PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, PreparedEffectBank, PreparedNativeEffect, PreparedNativeEffectBank, PreparedPorts, PreparedSidechainPort, StatePayloadInput, StatePayloadOutput, StatePayloadSizes, @@ -149,13 +149,10 @@ pub fn point( value: f32, ) -> PreparedAutomationSpan { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index, - start_sample: first_sample, - end_sample: first_sample, - start_value: value, - end_value: value, + sample: first_sample, + value, } } diff --git a/crates/parametric-eq/src/lib.rs b/crates/parametric-eq/src/lib.rs index 295f1d462..84db23985 100644 --- a/crates/parametric-eq/src/lib.rs +++ b/crates/parametric-eq/src/lib.rs @@ -6643,8 +6643,7 @@ mod boundary_fold { mod target_application { use super::*; use effect_contract::{ - AutomationSpanKind, EffectTargetRequest, NativeEffectTargetPreparation, - PreparedAutomationSpan, + EffectTargetRequest, NativeEffectTargetPreparation, PreparedAutomationSpan, }; fn request<'a>(values: &'a [InitialParameterValue]) -> PrepareEffectRequest<'a> { @@ -6787,13 +6786,10 @@ mod target_application { None, 0, &[PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 2, - start_sample: 0, - end_sample: 0, - start_value: 2_000.0, - end_value: 2_000.0, + sample: 0, + value: 2_000.0, }], 128, ) @@ -9641,13 +9637,10 @@ mod padded_banks { // Two spans on the last member, three on the padded lane. let span = PreparedAutomationSpan { - kind: effect_contract::AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 2, - start_sample: 0, - end_sample: 0, - start_value: 500.0, - end_value: 500.0, + sample: 0, + value: 500.0, }; let automation = [span; 5]; let mut offsets = vec![0_u32; lanes + 1]; diff --git a/crates/parametric-eq/tests/contract.rs b/crates/parametric-eq/tests/contract.rs index 0a1e093cc..1853cb20b 100644 --- a/crates/parametric-eq/tests/contract.rs +++ b/crates/parametric-eq/tests/contract.rs @@ -292,10 +292,6 @@ fn malformed_automation_rejects_each_span_without_losing_valid_targets() { let mut effect = ParametricEqFactory .prepare(request(&values, false)) .expect("prepare"); - let mut wrong_time = point(5, ParameterChannel::Left, 1, 0.5); - wrong_time.end_sample = 1; - let mut mismatched_point = point(2, ParameterChannel::Right, 0, 100.0); - mismatched_point.end_value = 200.0; let automation = [ point(3, ParameterChannel::Left, 0, 6.0), point(3, ParameterChannel::Left, 0, 8.0), @@ -303,11 +299,9 @@ fn malformed_automation_rejects_each_span_without_losing_valid_targets() { point(3, ParameterChannel::Right, 0, 7.0), point(0, ParameterChannel::Left, 0, 1.0), point(5, ParameterChannel::Both, 0, 0.5), - wrong_time, - mismatched_point, ]; let report = process_zeros(&mut effect, 0, 1, &automation); - assert_eq!(report.invalid_spans, 8); + assert_eq!(report.invalid_spans, 6); let (_, left, right) = snapshot(&effect); assert_eq!( f32::from_bits(band_word(&left, 0, 16)), diff --git a/crates/parametric-eq/tests/support/mod.rs b/crates/parametric-eq/tests/support/mod.rs index 1c4280b7c..3af07bdda 100644 --- a/crates/parametric-eq/tests/support/mod.rs +++ b/crates/parametric-eq/tests/support/mod.rs @@ -8,10 +8,10 @@ #![allow(dead_code, unreachable_pub)] use effect_contract::{ - AutomationSpanKind, EffectProcessBlock, EffectQuality as Quality, InitialParameterValue, - LinkMode, NativeEffectFactory, NativeEffectTargetPreparation, ParameterChannel, - PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, PreparedEffect, - PreparedEffectTarget, PreparedPorts, PreparedSidechainPort, ProcessReport, StatePayloadOutput, + EffectProcessBlock, EffectQuality as Quality, InitialParameterValue, LinkMode, + NativeEffectFactory, NativeEffectTargetPreparation, ParameterChannel, PrepareEffectLimits, + PrepareEffectRequest, PreparedAutomationSpan, PreparedEffect, PreparedEffectTarget, + PreparedPorts, PreparedSidechainPort, ProcessReport, StatePayloadOutput, }; use parametric_eq::{EQ_SECTION_COUNT, EqBandKind, PARAMETRIC_EQ_DESCRIPTOR, ParametricEqFactory}; @@ -244,13 +244,10 @@ pub fn point( value: f32, ) -> PreparedAutomationSpan { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index, - start_sample: sample, - end_sample: sample, - start_value: value, - end_value: value, + sample, + value, } } diff --git a/crates/rack/src/lib.rs b/crates/rack/src/lib.rs index af3e27ff5..3f76f5d92 100644 --- a/crates/rack/src/lib.rs +++ b/crates/rack/src/lib.rs @@ -1837,13 +1837,10 @@ impl LiveControlEffectBankStage { /// The value an unused staging slot holds. It is never handed to a bank: only `[..offsets[lanes]]` /// is, and every span inside that range was written by this block's drain. const IDLE_SPAN: PreparedAutomationSpan = PreparedAutomationSpan { - kind: effect_contract::AutomationSpanKind::Point, channel: effect_contract::ParameterChannel::Both, parameter_index: 0, - start_sample: 0, - end_sample: 0, - start_value: 0.0, - end_value: 0.0, + sample: 0, + value: 0.0, }; impl BankStage for LiveControlEffectBankStage { diff --git a/crates/rack/tests/live_control_bank.rs b/crates/rack/tests/live_control_bank.rs index e54142234..7d5639d8d 100644 --- a/crates/rack/tests/live_control_bank.rs +++ b/crates/rack/tests/live_control_bank.rs @@ -9,12 +9,12 @@ use std::sync::{Arc, Mutex}; use effect_contract::{ - AutomationSpanKind, BankProcessReport, BankWidth, EffectBankProcessBlock, EffectCellWriter, - EffectControlLane, EffectControlRecord, EffectId, EffectLaneShape, EffectLaneSpec, - EffectProgramKey, EffectQuality, LatencySamples, LinkMode, ParameterChannel, - ParameterChannelPolicy, PreparedBankMetadata, PreparedEffectBank, PreparedNativeEffectBank, - PreparedPorts, PreparedSidechainPort, ResetKind, StatePayloadError, StatePayloadInput, - StatePayloadOutput, StatePayloadSizes, TailSamples, effect_control_lane, + BankProcessReport, BankWidth, EffectBankProcessBlock, EffectCellWriter, EffectControlLane, + EffectControlRecord, EffectId, EffectLaneShape, EffectLaneSpec, EffectProgramKey, + EffectQuality, LatencySamples, LinkMode, ParameterChannel, ParameterChannelPolicy, + PreparedBankMetadata, PreparedEffectBank, PreparedNativeEffectBank, PreparedPorts, + PreparedSidechainPort, ResetKind, StatePayloadError, StatePayloadInput, StatePayloadOutput, + StatePayloadSizes, TailSamples, effect_control_lane, }; use engine::realtime::{GatePin, LiveSnapshot, LiveValuesSuperseded, RenderError, RevisionGate}; use rack::{ @@ -95,10 +95,9 @@ impl PreparedNativeEffectBank for MockGainBank { let end = block.automation_offsets[lane + 1] as usize; self.seen[lane] = end - start; for span in &block.automation[start..end] { - assert_eq!(span.kind, AutomationSpanKind::Point); - assert_eq!(span.start_sample, block.first_sample); + assert_eq!(span.sample, block.first_sample); if span.parameter_index == 0 { - self.gain[lane] = span.start_value; + self.gain[lane] = span.value; } } } diff --git a/crates/soft-clip/src/lib.rs b/crates/soft-clip/src/lib.rs index 2a6e93c83..291aeb780 100644 --- a/crates/soft-clip/src/lib.rs +++ b/crates/soft-clip/src/lib.rs @@ -23,9 +23,9 @@ //! layout version is rejected with `effect.state.version`. use effect_contract::{ - AutomationRate, AutomationSpanKind, BankProcessReport, BankWidth, EffectBankProcessBlock, - EffectDescriptor, EffectPrepareError, EffectProcessBlock, EffectQuality, InitialParameterValue, - LatencySamples, LinkModeSet, NativeEffectFactory, ParameterChannel, ParameterChannelPolicy, + AutomationRate, BankProcessReport, BankWidth, EffectBankProcessBlock, EffectDescriptor, + EffectPrepareError, EffectProcessBlock, EffectQuality, InitialParameterValue, LatencySamples, + LinkModeSet, NativeEffectFactory, ParameterChannel, ParameterChannelPolicy, ParameterDescriptor, ParameterDomain, ParameterId, ParameterMapping, ParameterUnit, PortDescriptor, PortId, PortLayout, PortRole, PrepareEffectBankRequest, PrepareEffectRequest, PreparedAutomationSpan, PreparedBankMetadata, PreparedEffect, PreparedEffectBank, @@ -624,18 +624,15 @@ fn apply_automation( }; let valid = span_index < automation_capacity as usize && parameter < PARAMETER_COUNT - && span.kind == AutomationSpanKind::Point - && span.start_sample == first_sample - && span.end_sample == first_sample - && span.start_value.to_bits() == span.end_value.to_bits() - && parameter_value_valid(parameter, span.start_value) + && span.sample == first_sample + && parameter_value_valid(parameter, span.value) && prior.is_none_or(|previous| order > previous) && pending[channel][parameter].is_none(); if !valid { report.invalid_spans = report.invalid_spans.saturating_add(1); continue; } - let Some(value) = convert_parameter(parameter, normalize_zero(span.start_value)) else { + let Some(value) = convert_parameter(parameter, normalize_zero(span.value)) else { report.invalid_spans = report.invalid_spans.saturating_add(1); continue; }; diff --git a/crates/soft-clip/src/padding_tests.rs b/crates/soft-clip/src/padding_tests.rs index e4eeb2d40..d58284f37 100644 --- a/crates/soft-clip/src/padding_tests.rs +++ b/crates/soft-clip/src/padding_tests.rs @@ -218,13 +218,10 @@ impl Draw { } let value = self.value(parameter); spans.push(PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: index as u32, - start_sample: first_sample, - end_sample: first_sample, - start_value: value, - end_value: value, + sample: first_sample, + value, }); } } @@ -372,13 +369,10 @@ fn render(padded: &Padded<'_>, blocks: &[Block], context: &str) -> Vec> spans.extend( [ParameterChannel::Both, ParameterChannel::Left].map(|channel| { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: 0, - start_sample: first_sample, - end_sample: first_sample, - start_value: 6.0, - end_value: 6.0, + sample: first_sample, + value: 6.0, } }), ); @@ -931,13 +925,10 @@ fn planted_nonfinite_state(width: BankWidth) { } = scalar(request(&values[source], 48_000, LinkMode::DualMono)); // A drive ramp set at the poisoned block's first sample is mid-flight when D7 fires. let ramp = [PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 0, - start_sample: (3 * D7_FRAMES) as u64, - end_sample: (3 * D7_FRAMES) as u64, - start_value: 30.0, - end_value: 30.0, + sample: (3 * D7_FRAMES) as u64, + value: 30.0, }]; for block in 0..5 { let context = format!("{context}, block {block}"); diff --git a/crates/soft-clip/tests/contract.rs b/crates/soft-clip/tests/contract.rs index 06a534e38..ee02fd6d6 100644 --- a/crates/soft-clip/tests/contract.rs +++ b/crates/soft-clip/tests/contract.rs @@ -294,20 +294,15 @@ fn automation_rejects_everything_but_canonical_ordered_points() { let mut effect = prepare(&values); let mut left = vec![0.0_f32; 8]; let mut right = vec![0.0_f32; 8]; - let mut both = support::point(0, ParameterChannel::Both, 6.0, 0); - let mut ranged = support::point(1, ParameterChannel::Left, 6.0, 0); - ranged.end_sample = 4; - let mut out_of_domain = support::point(1, ParameterChannel::Right, 96.0, 0); - out_of_domain.end_value = 96.0; + let both = support::point(0, ParameterChannel::Both, 6.0, 0); + let out_of_domain = support::point(1, ParameterChannel::Right, 96.0, 0); let disordered = [ support::point(2, ParameterChannel::Left, 0.5, 0), support::point(0, ParameterChannel::Left, 6.0, 0), ]; - both.end_value = 6.0; - ranged.end_value = 6.0; - let spans = [both, ranged, out_of_domain, disordered[0], disordered[1]]; + let spans = [both, out_of_domain, disordered[0], disordered[1]]; let report = process(effect.as_mut(), &mut left, &mut right, 0, &spans); - assert_eq!(report.invalid_spans, 4); + assert_eq!(report.invalid_spans, 3); assert_eq!(report.sanitized_main_samples, 0); } diff --git a/crates/soft-clip/tests/support/mod.rs b/crates/soft-clip/tests/support/mod.rs index 2aaf9bcea..9b3b0f11e 100644 --- a/crates/soft-clip/tests/support/mod.rs +++ b/crates/soft-clip/tests/support/mod.rs @@ -228,11 +228,8 @@ pub fn point( PreparedAutomationSpan { parameter_index: parameter, channel, - kind: effect_contract::AutomationSpanKind::Point, - start_sample: first_sample, - end_sample: first_sample, - start_value: value, - end_value: value, + sample: first_sample, + value, } } diff --git a/crates/transient-shaper/src/lib.rs b/crates/transient-shaper/src/lib.rs index d3e477201..794f49fca 100644 --- a/crates/transient-shaper/src/lib.rs +++ b/crates/transient-shaper/src/lib.rs @@ -38,15 +38,15 @@ //! from the bytes: no heap value is created or dropped, and the payload calls are render-safe. use effect_contract::{ - AutomationRate, AutomationSpanKind, BankProcessReport, BankWidth, EffectBankProcessBlock, - EffectDescriptor, EffectPrepareError, EffectProcessBlock, EffectQuality, InitialParameterValue, - LatencySamples, LinkMode, LinkModeSet, NativeEffectFactory, ParameterChannel, - ParameterChannelPolicy, ParameterDescriptor, ParameterDomain, ParameterId, ParameterMapping, - ParameterUnit, PortDescriptor, PortId, PortLayout, PortRole, PrepareEffectBankRequest, - PrepareEffectRequest, PreparedAutomationSpan, PreparedBankMetadata, PreparedEffect, - PreparedEffectBank, PreparedEffectMetadata, PreparedNativeEffect, PreparedNativeEffectBank, - ProcessReport, ResetKind, SmoothingRule, StatePayloadError, StatePayloadInput, - StatePayloadOutput, StatePayloadSizes, TailSamples, expected_prepared_metadata, + AutomationRate, BankProcessReport, BankWidth, EffectBankProcessBlock, EffectDescriptor, + EffectPrepareError, EffectProcessBlock, EffectQuality, InitialParameterValue, LatencySamples, + LinkMode, LinkModeSet, NativeEffectFactory, ParameterChannel, ParameterChannelPolicy, + ParameterDescriptor, ParameterDomain, ParameterId, ParameterMapping, ParameterUnit, + PortDescriptor, PortId, PortLayout, PortRole, PrepareEffectBankRequest, PrepareEffectRequest, + PreparedAutomationSpan, PreparedBankMetadata, PreparedEffect, PreparedEffectBank, + PreparedEffectMetadata, PreparedNativeEffect, PreparedNativeEffectBank, ProcessReport, + ResetKind, SmoothingRule, StatePayloadError, StatePayloadInput, StatePayloadOutput, + StatePayloadSizes, TailSamples, expected_prepared_metadata, }; use effect_runtime::bank::{NonFiniteReport, check_block, nonfinite_lane_mask}; use effect_runtime::envelope::{ArCoef, ar_one_pole_step}; @@ -797,14 +797,8 @@ fn apply_automation( }; let valid = span_index < automation_capacity as usize && parameter_index < PARAMETER_COUNT - && span.kind == AutomationSpanKind::Point - && span.start_sample == first_sample - && span.end_sample == first_sample - && span.start_value.to_bits() == span.end_value.to_bits() - && value_valid( - &TRANSIENT_SHAPER_PARAMETERS[parameter_index], - span.start_value, - ) + && span.sample == first_sample + && value_valid(&TRANSIENT_SHAPER_PARAMETERS[parameter_index], span.value) && last_order.is_none_or(|previous| order > previous) && pending[lane_index][parameter_index].is_none(); if !valid { @@ -812,7 +806,7 @@ fn apply_automation( continue; } last_order = Some(order); - pending[lane_index][parameter_index] = Some(normalize_zero(span.start_value)); + pending[lane_index][parameter_index] = Some(normalize_zero(span.value)); } for (parameter_index, (left_pending, right_pending)) in pending[0].iter().zip(pending[1].iter()).enumerate() diff --git a/crates/transient-shaper/src/padding_tests.rs b/crates/transient-shaper/src/padding_tests.rs index b7f85d43d..218ff4009 100644 --- a/crates/transient-shaper/src/padding_tests.rs +++ b/crates/transient-shaper/src/padding_tests.rs @@ -216,13 +216,10 @@ impl Draw { } let value = self.value(parameter); spans.push(PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: index as u32, - start_sample: first_sample, - end_sample: first_sample, - start_value: value, - end_value: value, + sample: first_sample, + value, }); } } @@ -370,13 +367,10 @@ fn render(padded: &Padded<'_>, blocks: &[Block], context: &str) -> Vec> spans.extend( [ParameterChannel::Both, ParameterChannel::Left].map(|channel| { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: 0, - start_sample: first_sample, - end_sample: first_sample, - start_value: 0.5, - end_value: 0.5, + sample: first_sample, + value: 0.5, } }), ); diff --git a/crates/transient-shaper/tests/common/mod.rs b/crates/transient-shaper/tests/common/mod.rs index 60ecea67a..eb9755776 100644 --- a/crates/transient-shaper/tests/common/mod.rs +++ b/crates/transient-shaper/tests/common/mod.rs @@ -6,8 +6,8 @@ #![allow(dead_code)] use effect_contract::{ - AutomationSpanKind, BankWidth, EffectPrepareError, EffectQuality, InitialParameterValue, - LinkMode, NativeEffectFactory, ParameterChannel, PrepareEffectBankRequest, PrepareEffectLimits, + BankWidth, EffectPrepareError, EffectQuality, InitialParameterValue, LinkMode, + NativeEffectFactory, ParameterChannel, PrepareEffectBankRequest, PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, PreparedEffect, PreparedEffectBank, PreparedNativeEffect, PreparedNativeEffectBank, PreparedPorts, PreparedSidechainPort, StatePayloadOutput, StatePayloadSizes, @@ -225,13 +225,10 @@ pub(crate) fn point( value: f32, ) -> PreparedAutomationSpan { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index, - start_sample: first_sample, - end_sample: first_sample, - start_value: value, - end_value: value, + sample: first_sample, + value, } } diff --git a/crates/true-peak-limiter/src/lib.rs b/crates/true-peak-limiter/src/lib.rs index b67bca971..8d8f7c262 100644 --- a/crates/true-peak-limiter/src/lib.rs +++ b/crates/true-peak-limiter/src/lib.rs @@ -48,17 +48,17 @@ pub mod corpus; use effect_contract::control::{PreparedReason, RebuildReason}; use effect_contract::{ - AutomationRate, AutomationSpanKind, BankProcessReport, EffectBankProcessBlock, - EffectDescriptor, EffectPrepareError, EffectProcessBlock, EffectQuality, InitialParameterValue, - LatencySamples, LinkMode, LinkModeSet, NativeEffectFactory, ObservationCadence, - ObservationChannels, ObservationCost, ObservationDescriptor, ObservationFold, ObservationKind, - ObservationSample, ObservationTapId, ParameterChannel, ParameterChannelPolicy, - ParameterDescriptor, ParameterDomain, ParameterId, ParameterMapping, ParameterUnit, - PortDescriptor, PortId, PortLayout, PortRole, PrepareEffectBankRequest, PrepareEffectRequest, - PreparedAutomationSpan, PreparedBankMetadata, PreparedEffect, PreparedEffectBank, - PreparedEffectMetadata, PreparedNativeEffect, PreparedNativeEffectBank, ProcessReport, - ResetKind, SmoothingRule, StatePayloadError, StatePayloadInput, StatePayloadOutput, - StatePayloadSizes, TailSamples, expected_prepared_metadata, + AutomationRate, BankProcessReport, EffectBankProcessBlock, EffectDescriptor, + EffectPrepareError, EffectProcessBlock, EffectQuality, InitialParameterValue, LatencySamples, + LinkMode, LinkModeSet, NativeEffectFactory, ObservationCadence, ObservationChannels, + ObservationCost, ObservationDescriptor, ObservationFold, ObservationKind, ObservationSample, + ObservationTapId, ParameterChannel, ParameterChannelPolicy, ParameterDescriptor, + ParameterDomain, ParameterId, ParameterMapping, ParameterUnit, PortDescriptor, PortId, + PortLayout, PortRole, PrepareEffectBankRequest, PrepareEffectRequest, PreparedAutomationSpan, + PreparedBankMetadata, PreparedEffect, PreparedEffectBank, PreparedEffectMetadata, + PreparedNativeEffect, PreparedNativeEffectBank, ProcessReport, ResetKind, SmoothingRule, + StatePayloadError, StatePayloadInput, StatePayloadOutput, StatePayloadSizes, TailSamples, + expected_prepared_metadata, }; use effect_runtime::bank::{ NonFiniteReport, block_is_positive_zero, check_block, nonfinite_lane_mask, @@ -3951,12 +3951,9 @@ fn apply_automation( }; let valid = span_index < automation_capacity as usize && parameter < RAMP_COUNT - && span.kind == AutomationSpanKind::Point - && span.start_sample == first_sample - && span.end_sample == first_sample - && span.start_value.to_bits() == span.end_value.to_bits() - && parameter_value_valid(&PARAMETER_SPECS[parameter], span.start_value) - && !is_negative_zero(span.start_value) + && span.sample == first_sample + && parameter_value_valid(&PARAMETER_SPECS[parameter], span.value) + && !is_negative_zero(span.value) && last_order.is_none_or(|previous| order > previous) && pending[channel][parameter].is_none(); if !valid { @@ -3964,7 +3961,7 @@ fn apply_automation( continue; } last_order = Some(order); - pending[channel][parameter] = Some(normalize_zero(span.start_value)); + pending[channel][parameter] = Some(normalize_zero(span.value)); } for (channel, state) in [left, right].into_iter().enumerate() { if let Some(value) = pending[channel][0] { @@ -7172,13 +7169,10 @@ mod tests { // One more silent block, this time carrying a point that restates the ceiling it already // holds. It must be rendered rather than skipped. let restated = [PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 0, - start_sample: 16 * 128, - end_sample: 16 * 128, - start_value: -6.0, - end_value: -6.0, + sample: 16 * 128, + value: -6.0, }]; let mut left = vec![0.0_f32; 128]; let mut right = vec![0.0_f32; 128]; @@ -7311,13 +7305,10 @@ mod tests { let mut right = silence_plane(block, FRAMES, 1, quiet, 0.05, true); let first_sample = (block * FRAMES) as u64; let retarget = [PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 0, - start_sample: first_sample, - end_sample: first_sample, - start_value: -3.0, - end_value: -3.0, + sample: first_sample, + value: -3.0, }]; let spans: &[PreparedAutomationSpan] = if block == RETARGET_BLOCK { &retarget @@ -7513,13 +7504,10 @@ mod tests { let first_sample = 16 * 128_u64; let restated: Vec = (0..lanes) .map(|_| PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 0, - start_sample: first_sample, - end_sample: first_sample, - start_value: -6.0, - end_value: -6.0, + sample: first_sample, + value: -6.0, }) .collect(); let offsets: Vec = (0..=lanes).map(|track| track as u32).collect(); @@ -8766,31 +8754,22 @@ mod tests { .expect("prepare"); let spans = [ PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 0, - start_sample: 0, - end_sample: 0, - start_value: -6.0, - end_value: -6.0, + sample: 0, + value: -6.0, }, PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Both, parameter_index: 1, - start_sample: 0, - end_sample: 0, - start_value: 500.0, - end_value: 500.0, + sample: 0, + value: 500.0, }, PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 2, - start_sample: 0, - end_sample: 0, - start_value: 1.0, - end_value: 1.0, + sample: 0, + value: 1.0, }, ]; let mut left = vec![0.0_f32; 8]; @@ -8827,13 +8806,10 @@ mod tests { assert_eq!(snapshot(&effect), fresh); let spans = [PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 0, - start_sample: 0, - end_sample: 0, - start_value: -3.0, - end_value: -3.0, + sample: 0, + value: -3.0, }]; let mut left = vec![0.0_f32; 8]; let mut right = vec![0.0_f32; 8]; @@ -9403,13 +9379,10 @@ mod tests { value: f32, ) -> PreparedAutomationSpan { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: parameter, - start_sample: first_sample, - end_sample: first_sample, - start_value: value, - end_value: value, + sample: first_sample, + value, } } diff --git a/crates/true-peak-limiter/tests/allocation.rs b/crates/true-peak-limiter/tests/allocation.rs index ef493a93b..c477f305f 100644 --- a/crates/true-peak-limiter/tests/allocation.rs +++ b/crates/true-peak-limiter/tests/allocation.rs @@ -12,10 +12,9 @@ use bench_support::alloc; use lane::Backend; use effect_contract::{ - AutomationSpanKind, BankWidth, EffectBankProcessBlock, EffectProcessBlock, EffectQuality, - InitialParameterValue, LinkMode, NativeEffectFactory, ParameterChannel, - PrepareEffectBankRequest, PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, - PreparedPorts, PreparedSidechainPort, ResetKind, + BankWidth, EffectBankProcessBlock, EffectProcessBlock, EffectQuality, InitialParameterValue, + LinkMode, NativeEffectFactory, ParameterChannel, PrepareEffectBankRequest, PrepareEffectLimits, + PrepareEffectRequest, PreparedAutomationSpan, PreparedPorts, PreparedSidechainPort, ResetKind, }; use true_peak_limiter::{ TRUE_PEAK_LIMITER_DESCRIPTOR, TRUE_PEAK_LIMITER_PARAMETERS, TruePeakLimiterFactory, @@ -96,13 +95,10 @@ fn request(values: &[InitialParameterValue]) -> PrepareEffectRequest<'_> { fn automation(block: usize) -> [PreparedAutomationSpan; 1] { let value = if block.is_multiple_of(2) { -3.0 } else { -9.0 }; [PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Left, parameter_index: 0, - start_sample: (block * 128) as u64, - end_sample: (block * 128) as u64, - start_value: value, - end_value: value, + sample: (block * 128) as u64, + value, }] } diff --git a/crates/true-peak-limiter/tests/mono_collapse.rs b/crates/true-peak-limiter/tests/mono_collapse.rs index f66d4cbd9..96c66eeec 100644 --- a/crates/true-peak-limiter/tests/mono_collapse.rs +++ b/crates/true-peak-limiter/tests/mono_collapse.rs @@ -10,8 +10,8 @@ //! unobservable, so a corpus below the ceiling would say nothing about half of this kernel's state. use effect_contract::{ - AutomationSpanKind, BankWidth, EffectBankProcessBlock, EffectQuality, InitialParameterValue, - LinkMode, NativeEffectFactory, ParameterChannel, PrepareEffectBankRequest, PrepareEffectLimits, + BankWidth, EffectBankProcessBlock, EffectQuality, InitialParameterValue, LinkMode, + NativeEffectFactory, ParameterChannel, PrepareEffectBankRequest, PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, PreparedEffectBank, PreparedNativeEffectBank, PreparedPorts, PreparedSidechainPort, StatePayloadOutput, StatePayloadSizes, }; @@ -204,13 +204,10 @@ fn automation(step: usize, lanes: usize) -> (Vec, Vec PreparedAutomationSpan { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index: parameter, - start_sample: first, - end_sample: first, - start_value: value, - end_value: value, + sample: first, + value, } } diff --git a/docs/EFFECT_CONTRACT_V1.md b/docs/EFFECT_CONTRACT_V1.md index 474d1222e..23d2dd3b5 100644 --- a/docs/EFFECT_CONTRACT_V1.md +++ b/docs/EFFECT_CONTRACT_V1.md @@ -296,13 +296,15 @@ One-pole-99 likewise precomputes `a = exp(ln(0.01)/N)` and `1-a` once, then `effect_contract::ParameterSmoother` states the same law for the control plane, and `effect-runtime/tests/contract_ramp_identity.rs` proves the two agree bit for bit. -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. -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 +V1 runtime automation is `PreparedAutomationSpan`s: each names one parameter channel, one `sample` +(equal to the block's first sample) and one `value`. They are validated off render by +`validate_automation_block`; an effect trusts the slice it is given. A span has no kind and no +end. The `Step`, `Linear` and `Exponential` span kinds (raw values 2 to 4 of the retired +`AutomationSpanKind`), `automation_segment_value` and the `Sample` automation rate (raw value 1) are +retired (#1636): no producer stages them and no effect accepts them, and #1686 removed the kind +and the end that only a moving span could use. `AutomationRate::from_raw` refuses 1, and the +retired 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 @@ -353,7 +355,7 @@ and is an effect-identity change, never a contract cleanup. Per-lane audio, dela 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, clears histories and snaps every parameter -ramp in flight to its target (runtime automation is `Point` spans only, so no span stays active). +ramp in flight to its target (a runtime automation span names one sample and one value, so no span stays active). ## Stable diagnostics diff --git a/tools/audit/src/compressor.rs b/tools/audit/src/compressor.rs index 5d49f5072..5e7134264 100644 --- a/tools/audit/src/compressor.rs +++ b/tools/audit/src/compressor.rs @@ -11,10 +11,10 @@ use bench_support::alloc as bench_alloc; use compressor::CompressorFactory; use effect_contract::{ - AutomationSpanKind, BankWidth, EffectBankProcessBlock, EffectProcessBlock, EffectQuality, - InitialParameterValue, LinkMode, NativeEffectFactory, ParameterChannel, PortId, - PrepareEffectBankRequest, PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, - PreparedNativeEffect, PreparedNativeEffectBank, PreparedPorts, PreparedSidechainPort, + BankWidth, EffectBankProcessBlock, EffectProcessBlock, EffectQuality, InitialParameterValue, + LinkMode, NativeEffectFactory, ParameterChannel, PortId, PrepareEffectBankRequest, + PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, PreparedNativeEffect, + PreparedNativeEffectBank, PreparedPorts, PreparedSidechainPort, }; use engine::realtime::audit; use lane::Backend; @@ -96,13 +96,10 @@ pub(crate) fn main() { } else { -12.0 }; - points[0].start_value = value; - points[0].end_value = value; - points[1].start_value = value; - points[1].end_value = value; + points[0].value = value; + points[1].value = value; for point in &mut points { - point.start_sample = first_sample; - point.end_sample = first_sample; + point.sample = first_sample; } automation = &points; } @@ -194,13 +191,10 @@ pub(crate) fn main() { /// A block-rate Point on one parameter of one channel. fn span(channel: ParameterChannel, parameter_index: u32, value: f32) -> PreparedAutomationSpan { PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index, - start_sample: 0, - end_sample: 0, - start_value: value, - end_value: value, + sample: 0, + value, } } diff --git a/tools/audit/src/delay.rs b/tools/audit/src/delay.rs index 8439cf160..4dbe3fc0b 100644 --- a/tools/audit/src/delay.rs +++ b/tools/audit/src/delay.rs @@ -9,10 +9,9 @@ use bench_support::alloc as bench_alloc; use delay::{DELAY_PARAMETERS, DelayFactory}; use effect_contract::{ - AutomationSpanKind, EffectProcessBlock, EffectQuality, InitialParameterValue, LinkMode, - NativeEffectFactory, ParameterChannel, PrepareEffectLimits, PrepareEffectRequest, - PreparedAutomationSpan, PreparedNativeEffect, PreparedPorts, PreparedSidechainPort, - ProcessReport, + EffectProcessBlock, EffectQuality, InitialParameterValue, LinkMode, NativeEffectFactory, + ParameterChannel, PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, + PreparedNativeEffect, PreparedPorts, PreparedSidechainPort, ProcessReport, }; use engine::realtime::audit; @@ -122,13 +121,10 @@ fn automation(block: u64, first_sample: u64) -> [PreparedAutomationSpan; 3] { let damping = [0.0_f32, 0.25, 0.995, 0.5]; let cross = [0.0_f32, 0.5, 1.0, 0.25]; let point = |parameter_index: u32, channel, value| PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel, parameter_index, - start_sample: first_sample, - end_sample: first_sample, - start_value: value, - end_value: value, + sample: first_sample, + value, }; [ point(0, ParameterChannel::Left, delay[step]), diff --git a/tools/console-workload/src/lib.rs b/tools/console-workload/src/lib.rs index 7fe2dc427..0f95a863b 100644 --- a/tools/console-workload/src/lib.rs +++ b/tools/console-workload/src/lib.rs @@ -1592,8 +1592,8 @@ impl SessionRuntime { /// /// This is the production control path and nothing else: the value is read by the render /// thread at the top of the next block and staged as a single - /// [`AutomationSpanKind::Point`](effect_contract::AutomationSpanKind) span at that - /// block's first sample. One call per block therefore *is* "one Point span per block", by the + /// [`PreparedAutomationSpan`](effect_contract::PreparedAutomationSpan) at that + /// block's first sample. One call per block therefore *is* "one span per block", by the /// contract's own construction rather than by a hand-built span a benchmark asserts is /// equivalent. /// diff --git a/tools/console-workload/tests/paired_spans.rs b/tools/console-workload/tests/paired_spans.rs index 0d39ac873..2f563b4bb 100644 --- a/tools/console-workload/tests/paired_spans.rs +++ b/tools/console-workload/tests/paired_spans.rs @@ -35,17 +35,17 @@ use bench_support::digest::Sha256Sink; use console_workload::{ObservationArm, PlanConfig, SessionRuntime, Workload}; use effect_compiler::launch_native_effect_registry; use effect_contract::{ - AutomationRate, AutomationSpanKind, BankProcessReport, BankWidth, ChannelSymmetryWitness, - EffectBankProcessBlock, EffectCellWriter, EffectControlLane, EffectControlRecord, - EffectDescriptor, EffectLaneShape, EffectLaneSpec, EffectProcessBlock, EffectProgramKey, - EffectQuality, EffectTargetError, EffectTargetRequest, InitialParameterValue, LinkMode, - NativeEffectFactory, ObservationSample, ParameterChannel, ParameterChannelPolicy, - ParameterDescriptor, ParameterDomain, PortRole, PrepareEffectBankRequest, PrepareEffectLimits, - PrepareEffectRequest, PreparedAutomationSpan, PreparedEffect, PreparedEffectBank, - PreparedEffectTarget, PreparedNativeEffectBank, PreparedPorts, PreparedSidechainPort, - ProcessBlockError, ResetKind, ResponseAnalysisError, ResponseSnapshotRequest, - ResponseSnapshotSummary, SeamSide, StatePayloadError, StatePayloadInput, StatePayloadOutput, - StatePayloadSizes, default_initial_values, effect_control_lane, + AutomationRate, BankProcessReport, BankWidth, ChannelSymmetryWitness, EffectBankProcessBlock, + EffectCellWriter, EffectControlLane, EffectControlRecord, EffectDescriptor, EffectLaneShape, + EffectLaneSpec, EffectProcessBlock, EffectProgramKey, EffectQuality, EffectTargetError, + EffectTargetRequest, InitialParameterValue, LinkMode, NativeEffectFactory, ObservationSample, + ParameterChannel, ParameterChannelPolicy, ParameterDescriptor, ParameterDomain, PortRole, + PrepareEffectBankRequest, PrepareEffectLimits, PrepareEffectRequest, PreparedAutomationSpan, + PreparedEffect, PreparedEffectBank, PreparedEffectTarget, PreparedNativeEffectBank, + PreparedPorts, PreparedSidechainPort, ProcessBlockError, ResetKind, ResponseAnalysisError, + ResponseSnapshotRequest, ResponseSnapshotSummary, SeamSide, StatePayloadError, + StatePayloadInput, StatePayloadOutput, StatePayloadSizes, default_initial_values, + effect_control_lane, }; use engine::realtime::{GatePin, LiveValuesSuperseded, RenderError, RevisionGate}; use lane::Backend; @@ -1871,13 +1871,10 @@ fn every_launch_effect_applies_a_twin_pair_with_channel_symmetric_validity() { let (gate, mut pin) = RevisionGate::pair(0); let mut staging = vec![ PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Both, parameter_index: 0, - start_sample: 0, - end_sample: 0, - start_value: 0.0, - end_value: 0.0, + sample: 0, + value: 0.0, }; control.parameter_cells().max(2) ]; @@ -1979,13 +1976,10 @@ fn every_launch_effect_refuses_a_staging_window_that_is_not_its_capacity() { let window = |spans: usize| { vec![ PreparedAutomationSpan { - kind: AutomationSpanKind::Point, channel: ParameterChannel::Both, parameter_index: 0, - start_sample: 0, - end_sample: 0, - start_value: 0.0, - end_value: 0.0, + sample: 0, + value: 0.0, }; spans ] From 61610609526b0d9d3d250d9ce6dde01c4cd8e6de Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 18:38:52 +0000 Subject: [PATCH 03/10] Size the C ABI control frame for observation taps: the header rule (#1685, root ruling) Co-Authored-By: Claude Opus 5.5 --- crates/capi/include/miso_engine_v1.h | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/capi/include/miso_engine_v1.h b/crates/capi/include/miso_engine_v1.h index 4dd570915..2cbaf99a2 100644 --- a/crates/capi/include/miso_engine_v1.h +++ b/crates/capi/include/miso_engine_v1.h @@ -297,6 +297,13 @@ typedef struct miso_engine_v1_compile_limits { uint64_t maximum_meter_streams; uint64_t maximum_meter_items; uint64_t maximum_meter_bytes; + /* Largest control frame, in bytes. It also bounds the observation taps a + session can keep armed: one commit that ends them all reports them in one OBSERVATION_ENDED + event, which must fit this frame, so a subscription that would outgrow it is refused (#1685). + That event is 64 bytes plus, per armed tap, 64 bytes + pad8(strip ID bytes) + pad8(effect ID + bytes), where pad8 rounds up to a multiple of 8. Example: 64 insert meters whose strip and + effect IDs are 8 bytes or shorter need 64 + 64 * 80 = 5,184 bytes (about 5.1 KiB); with + 16-byte IDs, 6,208 bytes. The browser uses 16 KiB. */ uint64_t maximum_control_frame_bytes; uint64_t maximum_replay_bytes; uint64_t maximum_replay_entries; From 41812d284dcdd68aaf1ea5f94fccca8516e3690c Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 18:51:06 +0000 Subject: [PATCH 04/10] Validate builtin automation targets against their rows at preparation (#1645) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/author-session/SKILL.md | 3 + ...iltin-automation-targets-at-preparation.md | 46 +++ Cargo.lock | 1 + crates/capi/src/runtime/live_tests.rs | 116 +++++++ crates/controls/Cargo.toml | 4 +- crates/controls/src/automation.rs | 306 ++++++++++++++++-- crates/controls/src/builtin_rows.rs | 4 +- crates/controls/src/error.rs | 12 + crates/controls/tests/automation.rs | 268 +++++++++++++++ crates/host-core/tests/live_delta.rs | 52 +++ docs/SESSION_SCHEMA_V1.md | 25 +- tools/session-validator/tests/validate.rs | 17 + 12 files changed, 825 insertions(+), 29 deletions(-) diff --git a/.claude/skills/author-session/SKILL.md b/.claude/skills/author-session/SKILL.md index ec877beed..2b3c7194d 100644 --- a/.claude/skills/author-session/SKILL.md +++ b/.claude/skills/author-session/SKILL.md @@ -160,6 +160,9 @@ is `schema.unknown_field`; out of range is `numeric.out_of_schema_range` at | Two discontinuities of one entry less than 64 samples apart, at the later one's `start_sample` or `end_sample` | `automation.discontinuity_spacing` | typed-model | | A repeated entry `id` | `id.duplicate` | typed-model | | Segments out of order, overlapping or empty | `automation.out_of_order`, `automation.segment_overlap`, `automation.invalid_range` | typed-model | +| A segment whose unit is not the row's, or a value the row refuses (a fader above 24 dB, a filter above the rate's maximum), at `$.automation[id=].segments[]` | `control.domain` | prepare-effects | +| A `linear` or `exponential` segment on a toggle (polarity, mute) or enumeration row; `exponential` on a dB row or a row whose minimum is not above 0; a `linear` or `exponential` filter segment from or to `0` ("off" is reached only by a `step`) | `control.automation.shape` | prepare-effects | +| An HPF that can reach or pass an enabled LPF on a lane, at any time, with stored automation on either filter, at the LPF entry's (else the HPF entry's) segment | `control.automation.order` | prepare-effects | ## Buses: submix strips and their taps diff --git a/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md b/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md index 20d31390b..c89c81f01 100644 --- a/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md +++ b/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md @@ -272,3 +272,49 @@ Checked against `12d4a4f9d`. `crates/control-plane/src/control.rs`). - **Dependencies.** #1626, #1637, #1566 (in place of #1335's route). Obsolete draft 03a is not filed; this spec carries its shape rule. Batch R1. + +## Decision record: #1645 (batch 14a) + +- **Shipped, per the amendment.** `controls::automation_diagnostics` (`crates/controls/src/automation.rs`) + gains the shape rule and the curve form of the input filter order rule; `ControlError` gains + `AutomationShape` (`control.automation.shape`) and `AutomationOrder` (`control.automation.order`). + `controls` gains a normal dependency on `automation` and calls `automation::value_at` for every + evaluated value. No new call site: preparation, the classifier's step 2 and `session-validator` + stage `prepare-effects` already call the row rule, so browser and C ABI refuse alike. +- **Shape rule, every row.** A segment is reported once at `.segments[]`: `control.domain` + first, else `control.automation.shape`. Toggle or enumeration row: `step` only. `exponential`: + continuous, `minimum > 0`, unit not dB. A row with a `disabled_value`: a `linear` or + `exponential` segment has neither value equal to it. +- **Order rule.** As D2, per strip and lane (a `both` entry drives both lanes). Departures, each + for the code's truth: (1) the value at a sub-interval's right end is the left limit, evaluated on + the defining segment alone (`value_at(&[segment], b)`), because `value_at` on the whole table at + a segment's `end` returns the next segment's `start_value` when one begins there; (2) the rule + skips a lane whose HPF or LPF entry has its own fault (target, domain or shape), so it never reads + a curve that ramps through "off", and a fault is reported once; (3) a `both` entry failing both + lanes at one segment is reported once (identical code and path). Stage index: the row rule runs + at stage index 4 (`prepare-effects`), not 3 as gate 3 says. +- **Gates.** + - 1 and 2: `crates/controls/tests/automation.rs` + `builtin_rows_refuse_a_segment_by_unit_domain_and_shape`, + `input_filter_curves_keep_the_hpf_below_the_lpf` (adds one case: the LPF ride against an HPF + that stays on, refused in the gap's hold at the LPF entry's segment 0). PASS. + - 3: `tools/session-validator/tests/validate.rs` two `MUTATIONS` rows (fader ride in `linear` + units -> `control.domain`, polarity flip as `linear` -> `control.automation.shape`, both stage + index 4). PASS; `fixtures_distinguish_schema_examples_from_launch_effects` and `skill` PASS. + - 7: `crates/host-core/tests/live_delta.rs` `an_automation_the_builtin_rows_refuse_needs_a_rebuild`; + `crates/capi/src/runtime/live_tests.rs` + `a_builtin_automation_the_rows_refuse_is_refused_before_any_ack` (shape and order refusals on a + playing engine: `RESULT_COMPILE_REJECTED`, preparation's diagnostic, no revision, candidate or + event, next three blocks bit-identical). PASS. + - 6: `cargo test` for controls, session-validator, host-core (`test-support,control-provider`; + the `live_delta` target is `cfg(feature = "control-provider")`), protocol, builtins-compiler, + capi: PASS. `check-workspace-policy.sh`, `check-builtins-policy.sh`, `check-cross-targets.sh` + (no new `memset_pattern16`; the #1018 expected failures unchanged), `realtime-policy`, clippy on + the touched crates with `-D warnings`, `cargo fmt --check`: PASS. + - 5: no render code changed; the `audit capi` digest comparison is left to the coordinator's PR + evidence. +- **Docs.** `docs/SESSION_SCHEMA_V1.md` names both codes, the shape rule and the curve order rule; + the `author-session` skill's automation refusal table gains three rows at `prepare-effects`. +- **Not done here (successor, #1660):** a static HPF/LPF edit that crosses an automated curve + without changing the automation still classifies live, because the classifier's step 2 runs the + row rule only when the automation or activity changes. diff --git a/Cargo.lock b/Cargo.lock index 9bc4a95ea..cf08d775f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -346,6 +346,7 @@ dependencies = [ name = "controls" version = "0.1.0" dependencies = [ + "automation", "bench-support", "builtins", "effect-compiler", diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 771fa8a9d..f516e9ab0 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -4514,6 +4514,122 @@ fn an_automation_on_a_prepared_effect_parameter_is_refused_before_any_ack() { } } +// Issue #1645: the builtin rows' shape and filter-order rules on the C ABI's live path. + +/// An `UpsertAutomation` on `eq_session`'s `eq0` builtin row `control` (both lanes), one segment +/// over the first 960 samples. +fn builtin_automation_edit( + control: u32, + shape: session::AutomationShape, + unit: session::ParameterUnit, + (start_value, end_value): (f32, f32), +) -> SessionEdit { + let id = |text: &str| StableId::parse(text).expect("stable ID"); + SessionEdit::UpsertAutomation { + automation: session::Automation { + id: id("builtin-ride"), + target: session::ControlAddress { + owner: session::Owner::Strip(id("eq0")), + section: session::Section::Builtins, + control, + lane: session::ParameterChannel::Both, + }, + segments: vec![session::AutomationSegment { + shape, + start_sample: 0, + end_sample: 960, + start_value, + end_value, + unit, + }], + }, + } +} + +/// #1645 gate 7. On a playing engine, a transaction that only adds a builtin entry the row rule +/// refuses -- a mute that ramps (`control.automation.shape`), an HPF ride from 200 Hz that reaches +/// the static 1 kHz LPF (`control.automation.order`) -- is refused +/// with the compile rejection a rebuild's preparation gives: the revision, the committed model and +/// the reliable lane do not move, no candidate is prepared, and the following blocks are +/// bit-identical to an engine that never received it. +/// +/// Red if the classifier's row-rule check misses the shape or the order rule: the edit commits +/// live, acked, and its next rebuild refuses it. +#[test] +fn a_builtin_automation_the_rows_refuse_is_refused_before_any_ack() { + use session::{AutomationShape, ParameterUnit}; + let document = with_model(&eq_session(48_000), |model| { + let builtins = &mut model.tracks[0].builtins; + for lane in [&mut builtins.left, &mut builtins.right] { + lane.hpf_hz = 0.0; + lane.lpf_hz = 1_000.0; + } + }); + let mut rig = Rig::new(&document); + let mut untouched = Rig::new(&document); + for _ in 0..window(rig.latency(), 0, rig.quantum) { + assert_eq!(bits(&rig.step()), bits(&untouched.step()), "warm-up"); + } + let refused = [ + ( + builtin_automation_edit( + 6, + AutomationShape::Linear, + ParameterUnit::Linear, + (0.0, 1.0), + ), + "control.automation.shape", + ), + ( + builtin_automation_edit( + 3, + AutomationShape::Linear, + ParameterUnit::Hz, + (200.0, 1_000.0), + ), + "control.automation.order", + ), + ]; + for (edit, code) in refused { + let before = (refusal_state(&rig), rig.summary()); + assert_eq!( + rig.apply(core::slice::from_ref(&edit)), + crate::RESULT_COMPILE_REJECTED, + "{code}" + ); + let mut rejected = parse_session_json(&document).expect("model"); + protocol::apply_session_edit(&mut rejected, &edit).expect("the edit applies to the model"); + let rejected = session::canonical_session_json(&rejected).expect("canonical rejected"); + let Err(failure) = compile_children(&rejected, limits()) else { + panic!("{code}: the entry does not prepare") + }; + assert_eq!( + rig.last_error(), + failure.diagnostics, + "{code}: the refusal is preparation's diagnostic" + ); + assert!( + String::from_utf8_lossy(&failure.diagnostics).contains(code), + "{}", + String::from_utf8_lossy(&failure.diagnostics) + ); + assert_eq!( + (refusal_state(&rig), rig.summary()), + before, + "{code}: no revision, no candidate, no event" + ); + for _ in 0..3 { + let block = rig.block; + let expected = untouched.step(); + assert!( + expected.iter().any(|sample| *sample != 0.0), + "block {block}" + ); + assert_eq!(bits(&rig.step()), bits(&expected), "{code}: block {block}"); + } + } +} + // Issue #1552: the control list on the C ABI. /// Two tracks (each with an insert and the session's console slots), a submix that a send feeds, a diff --git a/crates/controls/Cargo.toml b/crates/controls/Cargo.toml index 5ce096e2b..9f643802b 100644 --- a/crates/controls/Cargo.toml +++ b/crates/controls/Cargo.toml @@ -10,9 +10,11 @@ publish.workspace = true name = "controls" # The control core (decision 17): it sits on the session model, the builtin rows and the launch -# effect registry (`effect-compiler`, for the effect rows) and on nothing above them. +# effect registry (`effect-compiler`, for the effect rows), and on `automation` for the one +# evaluation law the filter order rule reads (#1645); on nothing above them. # `builtins-compiler`, `host-core`, `protocol` and the hosts depend on it, never the reverse. [dependencies] +automation.workspace = true builtins.workspace = true effect-compiler.workspace = true effect-contract.workspace = true diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index ab9e9829d..20f8940a1 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -1,11 +1,19 @@ //! The row rule of a stored automation (decision 17, C10; #1566 D6): one function that holds every //! stored automation of a session to the control it names. -use session::{ControlAddress, Owner, Section, SessionModel}; +use automation::{Segment, Shape, value_at}; +use effect_contract::ParameterUnit; +use session::{ + Automation, AutomationSegment, AutomationShape, ControlAddress, Owner, Section, SessionModel, + StripRef, +}; -use crate::descriptor::{Lanes, check}; +use crate::ControlKind; +use crate::builtin_rows::{HPF_HZ, LPF_HZ}; +use crate::descriptor::{ControlDescriptor, Lanes, check}; use crate::effect_rows::{has_rows, session_unit}; use crate::error::ControlError; +use crate::lane::Side; use crate::registry::resolve; use session::Lane; @@ -58,10 +66,19 @@ pub struct ControlDiagnostic { /// on a pan strip); /// - `control.lane`: `left` or `right` on a shared row; /// - `control.domain`: a segment whose unit is not the row's unit, or a value (`start_value` or -/// `end_value`) the row refuses. +/// `end_value`) the row refuses; +/// - `control.automation.shape`: a segment whose shape the row does not take (#1645): a toggle or +/// enumeration row takes only `step`; `exponential` only on a continuous row whose minimum is +/// strictly positive and whose unit is not dB; a `linear` or `exponential` segment never has a +/// value equal to the row's disabled value (a filter's "off" is reached only by a `step`); +/// - `control.automation.order`: on a strip lane where the HPF or the LPF is automated, the two +/// curves can come to an HPF at or above an enabled LPF (#1645 D2; see +/// `input_filter_order`). It is reported once per lane, at the LPF entry's segment when the LPF +/// is automated, else at the HPF entry's. /// /// A target is reported once, at `.target`; its segments are judged only when the target is -/// accepted. A segment is reported once, at `.segments[]`. Whether the owner, the console slot +/// accepted. A segment is reported once, at `.segments[]`: a domain fault first, else a shape +/// fault. The order rule runs only on a lane whose automated filter entries have no other fault. Whether the owner, the console slot /// and the insert exist is the session validator's rule, not this one's; an address it names but the /// model lacks is `control.address.unknown` here too. A target in an effect that has no rows (a /// `cid` effect, a native effect the registry lacks) is not reported here: effect preparation @@ -71,49 +88,290 @@ pub struct ControlDiagnostic { #[must_use] pub fn automation_diagnostics(model: &SessionModel, sample_rate: u32) -> Vec { let mut diagnostics = Vec::new(); - for automation in &model.automation { + // Whether each entry has a fault of its own, so the order rule reads only sound curves. + let mut faulted = vec![false; model.automation.len()]; + for (index, automation) in model.automation.iter().enumerate() { let target = &automation.target; let at = |suffix: &str| format!("$.automation[id={}]{suffix}", automation.id.as_str()); let refuse = |error: ControlError, path: String| ControlDiagnostic { code: error.code(), path, }; + let before = diagnostics.len(); let descriptor = match resolve(model, target, sample_rate) { Ok(resolved) => resolved.descriptor, // An effect that carries no rows is refused by effect preparation with its own code // (`effect.native.unavailable`, `effect.third_party.unavailable_at_launch`); one fault is // reported once. - Err(_) if effect_has_no_rows(model, target) => continue, + Err(_) if effect_has_no_rows(model, target) => { + faulted[index] = true; + continue; + } Err(error) => { diagnostics.push(refuse(error, at(".target"))); + faulted[index] = true; continue; } }; - if !descriptor.automatable() { - diagnostics.push(refuse(ControlError::AutomationTarget, at(".target"))); - continue; - } + let target_fault = if !descriptor.automatable() { + Some(ControlError::AutomationTarget) // The target's own faults, in `check`'s order: an inactive row, then a lane the row lacks. - if !descriptor.active { - diagnostics.push(refuse(ControlError::Inactive, at(".target"))); - continue; - } - if descriptor.lanes == Lanes::Shared && target.lane != Lane::Both { - diagnostics.push(refuse(ControlError::Lane, at(".target"))); + } else if !descriptor.active { + Some(ControlError::Inactive) + } else if descriptor.lanes == Lanes::Shared && target.lane != Lane::Both { + Some(ControlError::Lane) + } else { + None + }; + if let Some(error) = target_fault { + diagnostics.push(refuse(error, at(".target"))); + faulted[index] = true; continue; } for (position, segment) in automation.segments.iter().enumerate() { - let refused = session_unit(descriptor.unit) != segment.unit - || [segment.start_value, segment.end_value] + let in_domain = session_unit(descriptor.unit) == segment.unit + && [segment.start_value, segment.end_value] .into_iter() - .any(|value| check(&descriptor, target.lane, value, None).is_err()); - if refused { - diagnostics.push(refuse( - ControlError::Domain, - at(&format!(".segments[{position}]")), - )); + .all(|value| check(&descriptor, target.lane, value, None).is_ok()); + let fault = if !in_domain { + Some(ControlError::Domain) + } else if !shape_fits(&descriptor, segment) { + Some(ControlError::AutomationShape) + } else { + None + }; + if let Some(error) = fault { + diagnostics.push(refuse(error, at(&format!(".segments[{position}]")))); + } + } + faulted[index] = diagnostics.len() > before; + } + for strip in model.strips() { + for side in [Side::Left, Side::Right] { + if let Some(diagnostic) = input_filter_order(model, &strip, side, &faulted) { + // A `both` entry on two lanes whose static values match fails both lanes at one + // segment: one fault, reported once. + if !diagnostics.contains(&diagnostic) { + diagnostics.push(diagnostic); + } } } } diagnostics } + +/// Whether the row takes the segment's shape (#1645 D1, the shape rule; the values are already in +/// the row's domain). +fn shape_fits(descriptor: &ControlDescriptor, segment: &AutomationSegment) -> bool { + let touches_disabled = descriptor + .disabled_value + .is_some_and(|disabled| segment.start_value == disabled || segment.end_value == disabled); + match segment.shape { + AutomationShape::Step => true, + AutomationShape::Linear => descriptor.kind == ControlKind::Continuous && !touches_disabled, + AutomationShape::Exponential => { + descriptor.kind == ControlKind::Continuous + && !touches_disabled + && descriptor.minimum > 0.0 + && descriptor.unit != ParameterUnit::Db + } + } +} + +/// The relative margin of the order rule: `2^-22`, four times the `f32` rounding bound, so that +/// after render rounds each ramped value once to `f32` no rendered pair ties or crosses. +const ORDER_MARGIN: f64 = 1.0 + 1.0 / 4_194_304.0; + +/// One side of a lane's filter pair: its automation entry (with its segments in the evaluator's +/// type) or its static value. +enum Curve<'a> { + Static(f32), + Automated { + entry: &'a Automation, + segments: Vec, + }, +} + +/// What one side does on a sub-interval `[a, b)` of the cut time axis. +#[derive(Clone, Copy)] +enum Piece { + /// A hold, a `step` or a static value. + Constant(f32), + /// A `linear` or `exponential` piece, with its values at `a` and (the left limit) at `b`. + Ramp { shape: Shape, at_a: f32, at_b: f32 }, +} + +impl Piece { + fn ends(self) -> (f64, f64) { + match self { + Self::Constant(value) => (f64::from(value), f64::from(value)), + Self::Ramp { at_a, at_b, .. } => (f64::from(at_a), f64::from(at_b)), + } + } +} + +fn stored_segment(segment: &AutomationSegment) -> Segment { + Segment { + start: segment.start_sample, + end: segment.end_sample, + start_value: segment.start_value, + end_value: segment.end_value, + shape: match segment.shape { + AutomationShape::Step => Shape::Step, + AutomationShape::Linear => Shape::Linear, + AutomationShape::Exponential => Shape::Exponential, + }, + } +} + +fn node_time(sample: u64) -> i64 { + i64::try_from(sample).unwrap_or(i64::MAX) +} + +impl<'a> Curve<'a> { + /// The side's automation entry, or its static value when it is not automated. + fn new(entry: Option<&'a Automation>, value: f32) -> Self { + match entry { + Some(entry) => Self::Automated { + entry, + segments: entry.segments.iter().map(stored_segment).collect(), + }, + None => Self::Static(value), + } + } + + /// The index of the segment that defines the curve's value at `a`: the first segment for the + /// leading hold, the held segment in a gap or after the last one. + fn defining(segments: &[Segment], a: u64) -> usize { + segments + .partition_point(|segment| segment.start <= a) + .saturating_sub(1) + } + + /// The piece on `[a, b)`, where no cut of this curve lies strictly inside; `b` is `None` on the + /// last sub-interval, a hold. + fn piece(&self, a: u64, b: Option) -> Piece { + let segments = match self { + Self::Static(value) => return Piece::Constant(*value), + Self::Automated { segments, .. } => segments, + }; + let at_a = value_at(segments, node_time(a)); + let segment = segments.get(Self::defining(segments, a)); + match (segment, b) { + (Some(segment), Some(b)) + if segment.start <= a && a < segment.end && segment.shape != Shape::Step => + { + Piece::Ramp { + shape: segment.shape, + at_a, + // The segment alone: at its end the value is its own `end_value`, never the + // start of a segment that begins there. + at_b: value_at(core::slice::from_ref(segment), node_time(b)), + } + } + _ => Piece::Constant(at_a), + } + } +} + +/// Whether the HPF piece stays below the LPF piece on the sub-interval (#1645 D2). +fn ordered(hpf: Piece, lpf: Piece) -> bool { + match (hpf, lpf) { + // "Off" on either side leaves the sub-interval unchecked. + (Piece::Constant(off), _) | (_, Piece::Constant(off)) if off == 0.0 => true, + // Render does not round a constant: the static rule. + (Piece::Constant(h), Piece::Constant(l)) => h < l, + (Piece::Ramp { shape: hs, .. }, Piece::Ramp { shape: ls, .. }) if hs == ls => { + let (h_a, h_b) = hpf.ends(); + let (l_a, l_b) = lpf.ends(); + l_a > h_a * ORDER_MARGIN && l_b > h_b * ORDER_MARGIN + } + _ => { + let (h_a, h_b) = hpf.ends(); + let (l_a, l_b) = lpf.ends(); + l_a.min(l_b) > h_a.max(h_b) * ORDER_MARGIN + } + } +} + +/// The input filter order rule on one lane of one strip, at all times (#1645 D2). +/// +/// Each side's curve is its automation entry under the hold rule, or the constant of its static +/// value when it is not automated; a lane with neither side automated is the static rule's. The +/// time axis is cut at every `start_sample` and `end_sample` of either curve; on each +/// sub-interval each side is one monotone piece, so its extremes lie at the ends: +/// +/// - both constant: `H < L`, in `f32`; +/// - both `linear` or both `exponential`: `L > H·(1 + 2^-22)` at both ends (a linear difference +/// or a monotone ratio keeps the order inside); +/// - otherwise: `min(L) > max(H)·(1 + 2^-22)`, sufficient and not necessary, by design. +/// +/// It reads only `model`, so a classifier can run it on a candidate model. Entries with their own +/// fault (`faulted`) are not read: their curves may touch "off" inside a ramp. +fn input_filter_order( + model: &SessionModel, + strip: &StripRef<'_>, + side: Side, + faulted: &[bool], +) -> Option { + let lane = side.pick(&Lane::Left, &Lane::Right); + let entry = |control: u32| { + model.automation.iter().enumerate().find(|(_, automation)| { + let target = &automation.target; + target.owner == Owner::Strip(strip.id.clone()) + && target.section == Section::Builtins + && target.control == control + && (target.lane == *lane || target.lane == Lane::Both) + }) + }; + let (hpf_entry, lpf_entry) = (entry(HPF_HZ), entry(LPF_HZ)); + if hpf_entry.is_none() && lpf_entry.is_none() { + return None; + } + if [hpf_entry, lpf_entry] + .into_iter() + .flatten() + .any(|(index, _)| faulted.get(index).copied().unwrap_or(true)) + { + return None; + } + let channel = side.pick(&strip.builtins.left, &strip.builtins.right); + let hpf = Curve::new(hpf_entry.map(|(_, entry)| entry), channel.hpf_hz); + let lpf = Curve::new(lpf_entry.map(|(_, entry)| entry), channel.lpf_hz); + let mut cuts = vec![0_u64]; + for found in [&hpf, &lpf] { + if let Curve::Automated { segments, .. } = found { + cuts.extend( + segments + .iter() + .flat_map(|segment| [segment.start, segment.end]), + ); + } + } + cuts.sort_unstable(); + cuts.dedup(); + for (position, &a) in cuts.iter().enumerate() { + let b = cuts.get(position + 1).copied(); + if ordered(hpf.piece(a, b), lpf.piece(a, b)) { + continue; + } + // The LPF entry when the LPF is automated, else the HPF entry. + let reported = if matches!(lpf, Curve::Automated { .. }) { + &lpf + } else { + &hpf + }; + let Curve::Automated { entry, segments } = reported else { + unreachable!("one side is automated") + }; + return Some(ControlDiagnostic { + code: ControlError::AutomationOrder.code(), + path: format!( + "$.automation[id={}].segments[{}]", + entry.id.as_str(), + Curve::defining(segments, a) + ), + }); + } + None +} diff --git a/crates/controls/src/builtin_rows.rs b/crates/controls/src/builtin_rows.rs index 06784ac2e..4adf0702a 100644 --- a/crates/controls/src/builtin_rows.rs +++ b/crates/controls/src/builtin_rows.rs @@ -19,8 +19,8 @@ use crate::strip::StripMut; // The builtin IDs. `ids_match_the_descriptor_table` pins each to its row's name. const POLARITY_INVERT: u32 = 1; const TRIM_DB: u32 = 2; -const HPF_HZ: u32 = 3; -const LPF_HZ: u32 = 4; +pub(crate) const HPF_HZ: u32 = 3; +pub(crate) const LPF_HZ: u32 = 4; const FADER_DB: u32 = 5; const MUTE: u32 = 6; const MATRIX_LL: u32 = 7; diff --git a/crates/controls/src/error.rs b/crates/controls/src/error.rs index ebf91775f..e1471076a 100644 --- a/crates/controls/src/error.rs +++ b/crates/controls/src/error.rs @@ -24,6 +24,16 @@ pub enum ControlError { /// rebuild-only, derived or monitor row, or a setting). Raised by /// [`automation_diagnostics`](crate::automation_diagnostics) only; a write never meets it. AutomationTarget, + /// `control.automation.shape`: a stored automation segment whose shape its row does not take + /// (#1645): a toggle or enumeration row takes only `step`; `exponential` needs a continuous row + /// whose minimum is strictly positive and whose unit is not dB; a `linear` or `exponential` + /// segment never reaches a row's disabled value. Raised by + /// [`automation_diagnostics`](crate::automation_diagnostics) only. + AutomationShape, + /// `control.automation.order`: a strip lane's HPF and LPF curves (stored automation or static + /// value) can bring the HPF to or above an enabled LPF (#1645 D2). Raised by + /// [`automation_diagnostics`](crate::automation_diagnostics) only. + AutomationOrder, /// `control.ramp.fixed`: an explicit ramp length on a row whose ramp is fixed (an /// acknowledgement must not hide an ignored field). RampFixed, @@ -54,6 +64,8 @@ impl ControlError { Self::Lane => "control.lane", Self::Domain => "control.domain", Self::AutomationTarget => "control.automation.target", + Self::AutomationShape => "control.automation.shape", + Self::AutomationOrder => "control.automation.order", Self::RampFixed => "control.ramp.fixed", Self::Monitor => "control.monitor", Self::RampConflict => "control.ramp.conflict", diff --git a/crates/controls/tests/automation.rs b/crates/controls/tests/automation.rs index 1a01fa603..636dd5b31 100644 --- a/crates/controls/tests/automation.rs +++ b/crates/controls/tests/automation.rs @@ -324,3 +324,271 @@ fn a_target_in_an_unavailable_effect_is_reported_by_the_effect_alone() { model.automation = vec![one("ride", target, ParameterUnit::Db, 0.0)]; assert_eq!(automation_diagnostics(&model, RATE), Vec::new()); } + +// #1645: the shape rule and the input filter order rule, on the checked-in builtin automation. + +const POLARITY_INVERT: u32 = 1; +const HPF_HZ: u32 = 3; +const LPF_HZ: u32 = 4; +const MUTE: u32 = 6; + +/// `fixtures/session/v1/builtins-automation.json` (48 kHz; `vocal` has HPF 20 Hz and LPF 20 kHz on +/// both lanes; `bus` is a matrix-mode submix). +fn builtins_fixture() -> SessionModel { + session::parse_session_json(include_str!( + "../../../fixtures/session/v1/builtins-automation.json" + )) + .expect("fixture parses") +} + +fn ramp( + shape: AutomationShape, + unit: ParameterUnit, + (start_sample, end_sample): (u64, u64), + (start_value, end_value): (f32, f32), +) -> AutomationSegment { + AutomationSegment { + shape, + start_sample, + end_sample, + start_value, + end_value, + unit, + } +} + +/// The diagnostics of `model` with `added` stored after its own automation. +fn judged_with(mut model: SessionModel, added: Vec) -> Vec<(&'static str, String)> { + model.automation.extend(added); + let rate = model.sample_rate_hz; + automation_diagnostics(&model, rate) + .into_iter() + .map(|ControlDiagnostic { code, path }| (code, path)) + .collect() +} + +fn first_segment(name: &str) -> String { + format!("$.automation[id={name}].segments[0]") +} + +/// Gate 1: each row refuses a segment its unit, domain or shape rule refuses, once, at the +/// segment; the fixture, a filter's legal `exponential` and "off" by `step` are accepted. +/// +/// Red for each row rule left out, for a domain check that ignores the session rate (the 44.1 kHz +/// case), for a shape rule that refuses the filters' `exponential`, or for a filter rule that +/// refuses "off" by `step`. +#[test] +fn builtin_rows_refuse_a_segment_by_unit_domain_and_shape() { + use AutomationShape::{Exponential, Linear, Step}; + use ParameterUnit::{Db, Hz}; + let lin = ParameterUnit::Linear; + assert_eq!(judged_with(builtins_fixture(), Vec::new()), Vec::new()); + let vocal = |control| strip_builtin("vocal", control, Lane::Left); + let span = (0, 960); + let refused = [ + ( + "fader-unit", + vocal(FADER_DB), + ramp(Linear, lin, span, (0.0, 0.5)), + "control.domain", + ), + ( + "fader-high", + vocal(FADER_DB), + ramp(Linear, Db, span, (0.0, 24.001)), + "control.domain", + ), + ( + "matrix-high", + strip_builtin("bus", MATRIX_LL, Lane::Both), + ramp(Linear, lin, span, (1.0, 1.5)), + "control.domain", + ), + ( + "polarity-half", + vocal(POLARITY_INVERT), + ramp(Step, lin, span, (0.0, 0.5)), + "control.domain", + ), + ( + "mute-linear", + vocal(MUTE), + ramp(Linear, lin, span, (0.0, 1.0)), + "control.automation.shape", + ), + ( + "fader-exp", + vocal(FADER_DB), + ramp(Exponential, Db, span, (-6.0, -3.0)), + "control.automation.shape", + ), + ( + "hpf-from-off", + vocal(HPF_HZ), + ramp(Linear, Hz, span, (0.0, 100.0)), + "control.automation.shape", + ), + ( + "hpf-exp-to-off", + vocal(HPF_HZ), + ramp(Exponential, Hz, span, (100.0, 0.0)), + "control.automation.shape", + ), + ]; + for (name, target, segment, code) in refused { + assert_eq!( + judged_with( + builtins_fixture(), + vec![automation(name, target, vec![segment])] + ), + [(code, first_segment(name))], + "{name}" + ); + } + // 23 kHz is in domain at 48 kHz and above the 44.1 kHz bound (about 22,049 Hz). + let high = || { + automation( + "hpf-high", + vocal(HPF_HZ), + vec![ramp(Step, Hz, span, (23_000.0, 23_000.0))], + ) + }; + let mut at_44k1 = builtins_fixture(); + at_44k1.sample_rate_hz = 44_100; + let builtins = &mut at_44k1.tracks[0].builtins; + builtins.left.lpf_hz = 0.0; + builtins.right.lpf_hz = 0.0; + assert_eq!( + judged_with(at_44k1, vec![high()]), + [("control.domain", first_segment("hpf-high"))] + ); + let mut at_48k = builtins_fixture(); + let builtins = &mut at_48k.tracks[0].builtins; + builtins.left.lpf_hz = 0.0; + builtins.right.lpf_hz = 0.0; + assert_eq!(judged_with(at_48k, vec![high()]), Vec::new()); + let accepted = [ + ("hpf-exp", ramp(Exponential, Hz, span, (20.0, 200.0))), + ("hpf-on-by-step", ramp(Step, Hz, span, (0.0, 80.0))), + ]; + for (name, segment) in accepted { + assert_eq!( + judged_with( + builtins_fixture(), + vec![automation(name, vocal(HPF_HZ), vec![segment])] + ), + Vec::new(), + "{name}" + ); + } +} + +/// The fixture with a static LPF of `lpf_hz` on both of `vocal`'s lanes. +fn with_static_lpf(lpf_hz: f32) -> SessionModel { + let mut model = builtins_fixture(); + let builtins = &mut model.tracks[0].builtins; + builtins.left.lpf_hz = lpf_hz; + builtins.right.lpf_hz = lpf_hz; + model +} + +fn hpf(name: &str, segments: Vec) -> Automation { + automation(name, strip_builtin("vocal", HPF_HZ, Lane::Both), segments) +} + +fn lpf(name: &str, segments: Vec) -> Automation { + automation(name, strip_builtin("vocal", LPF_HZ, Lane::Both), segments) +} + +/// Gate 2: the HPF stays below an enabled LPF on every lane at all times: static LPF 1,000 Hz +/// unless stated. +/// +/// Red if the check samples only one curve's boundaries (the hold after a step), uses the end-point +/// rule on mixed pieces (the `exponential` against `linear` case), checks a lane whose HPF is off, +/// or reads the static value of an automated side; the last case is red if the rule reads anything +/// but the model it is given. +#[test] +fn input_filter_curves_keep_the_hpf_below_the_lpf() { + use AutomationShape::{Exponential, Linear, Step}; + use ParameterUnit::Hz; + let span = (0, 960); + let order = |name: &str, k: usize| { + vec![( + "control.automation.order", + format!("$.automation[id={name}].segments[{k}]"), + )] + }; + // Refused: a ride that reaches the LPF at its end. + assert_eq!( + judged_with( + with_static_lpf(1_000.0), + vec![hpf("h", vec![ramp(Linear, Hz, span, (200.0, 1_000.0))])] + ), + order("h", 0) + ); + // Refused on the hold after a step that is ordered while it lasts. + assert_eq!( + judged_with( + with_static_lpf(1_000.0), + vec![hpf("h", vec![ramp(Step, Hz, (0, 480), (100.0, 1_200.0))])] + ), + order("h", 0) + ); + // Refused, by design: mixed pieces, ordered at both ends, whose extremes overlap. Reported at + // the LPF entry. + assert_eq!( + judged_with( + with_static_lpf(1_000.0), + vec![ + hpf("h", vec![ramp(Exponential, Hz, span, (100.0, 900.0))]), + lpf("l", vec![ramp(Linear, Hz, span, (500.0, 2_000.0))]), + ] + ), + order("l", 0) + ); + // Accepted: a ride under the static LPF, and two ordered linear rides over the same samples. + assert_eq!( + judged_with( + with_static_lpf(1_000.0), + vec![hpf("h", vec![ramp(Linear, Hz, span, (100.0, 900.0))])] + ), + Vec::new() + ); + assert_eq!( + judged_with( + with_static_lpf(1_000.0), + vec![ + hpf("h", vec![ramp(Linear, Hz, span, (100.0, 900.0))]), + lpf("l", vec![ramp(Linear, Hz, span, (500.0, 2_000.0))]), + ] + ), + Vec::new() + ); + // Accepted: the HPF steps to "off" before an LPF ride passes below its former value. + assert_eq!( + judged_with( + with_static_lpf(1_000.0), + vec![ + hpf("h", vec![ramp(Step, Hz, (0, 480), (800.0, 0.0))]), + lpf("l", vec![ramp(Linear, Hz, (480, 960), (2_000.0, 300.0))]), + ] + ), + Vec::new() + ); + // The same LPF ride under an HPF that stays on is refused in the gap's hold: the LPF entry's + // segment 0 defines its value there. + assert_eq!( + judged_with( + with_static_lpf(1_000.0), + vec![ + hpf("h", vec![ramp(Step, Hz, (0, 480), (800.0, 800.0))]), + lpf("l", vec![ramp(Linear, Hz, (480, 960), (2_000.0, 300.0))]), + ] + ), + order("l", 0) + ); + // The static LPF moves under an unchanged HPF ride to 900 Hz. + let ride = || vec![hpf("h", vec![ramp(Linear, Hz, span, (100.0, 900.0))])]; + assert_eq!(judged_with(with_static_lpf(1_000.0), ride()), Vec::new()); + assert_eq!(judged_with(with_static_lpf(500.0), ride()), order("h", 0)); +} diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index 442bfb565..f3b636b8f 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -1221,6 +1221,58 @@ fn a_mode_switch_that_strands_an_automation_needs_a_rebuild() { ); } +/// #1645 gate 7. A delta that only adds a builtin entry the row rule refuses routes to the rebuild, +/// whose preparation refuses it: a fader ride in `linear` units (`control.domain`), a mute that +/// ramps (`control.automation.shape`) and an HPF ride that reaches a static LPF +/// (`control.automation.order`). The same delta with a valid fader ride stays live. +/// +/// Red if the classifier's row-rule check misses the shape or order rule: the edit commits live, +/// acked, and its next rebuild refuses it. +#[test] +fn an_automation_the_builtin_rows_refuse_needs_a_rebuild() { + let mut declared = two_tracks(); + declared.tracks[0].builtins.left.lpf_hz = 1_000.0; + declared.tracks[0].builtins.right.lpf_hz = 1_000.0; + let current = normalized(&declared); + type Make = fn(&mut Automation); + let refused: [(&str, Make); 3] = [ + ("fader in linear units", |ride| { + ride.segments[0].unit = ParameterUnit::Linear; + }), + ("mute ramp", |ride| { + ride.target.control = 6; + ride.segments[0].unit = ParameterUnit::Linear; + ride.segments[0].start_value = 0.0; + ride.segments[0].end_value = 1.0; + }), + ("HPF ride to the LPF", |ride| { + ride.target.control = 3; + ride.segments[0].unit = ParameterUnit::Hz; + ride.segments[0].start_value = 200.0; + ride.segments[0].end_value = 1_000.0; + }), + ]; + for (name, make) in refused { + assert_eq!( + classify(¤t, |next| { + let mut ride = fader_ride(next); + make(&mut ride); + next.automation.push(ride); + }), + Err(LiveRebuild::AutomationTarget), + "{name}" + ); + } + assert!( + classify(¤t, |next| { + let ride = fader_ride(next); + next.automation.push(ride); + }) + .is_ok(), + "a valid fader ride stays live" + ); +} + /// The model-only edits: #1260 D1's four and #1365 D4's `control_smoothing`. fn model_only_edits() -> [(&'static str, Edit); 5] { [ diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index 96d1fb79e..3eac545c4 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -277,8 +277,9 @@ lane and takes the segment's unit and values is the control core's rule (`controls::automation_diagnostics`), reported by host preparation and `session-validator` stage 5 beside the effect diagnostics: `control.address.unknown` (no row), `control.automation.target` (the row is rebuild-only, derived, a monitor row, a setting, or one of the two live rows that a ruling keeps out of automation: a send's `follows_mute`, a routing setting, and a strip's `matrix_mode`, a mode; #1632), `control.inactive` (the owner's shape -does not use the row), `control.lane` (`left` or `right` on a shared row) and `control.domain` (a -segment whose unit is not the row's, or a value the row refuses). A console target on a submix +does not use the row), `control.lane` (`left` or `right` on a shared row), `control.domain` (a +segment whose unit is not the row's, or a value the row refuses), `control.automation.shape` and +`control.automation.order` (#1645, below). A console target on a submix addresses that submix's entry for the slot (#1202). A session does not have to state a parameter value for the row it automates: the row exists whether or not the session states it. @@ -297,6 +298,26 @@ segment `[start_sample, end_sample)` it follows the segment: `linear` is `v0 + ( two, the lane holds that segment's `end_value`; a `step` takes its `end_value` at `end_sample`. The value after the last segment holds for ever. +**What a row takes (#1645).** Each segment is reported once, at +`$.automation[id=].segments[]`: `control.domain` first, else `control.automation.shape`. +The shape rule is one rule for every row: a toggle row (polarity, mute) or an enumeration takes only +`step`; `exponential` only on a continuous row whose minimum is above `0` and whose unit is not dB +(of the builtins, only HPF and LPF; never a fader, trim, pan or matrix row); and on a row with a +disabled value (a filter's `0`, "off") a `linear` or `exponential` segment has neither value equal +to it, so "off" is entered and left only by a `step`. + +**The input filter order, on curves (#1645).** On each strip lane where HPF (3) or LPF (4) is +automated (a `both` entry drives both lanes), each side is its entry under the hold rule, or the +constant of its static value. The time axis is cut at every `start_sample` and `end_sample` of +either curve; on each piece a side that is a constant `0` leaves it unchecked, and otherwise: +two constants need `H < L` (the static rule); two `linear` or two `exponential` pieces need +`L > H·(1 + 2^-22)` at both ends; any other pair needs `min(L) > max(H)·(1 + 2^-22)` over the two +ends, which is sufficient, not necessary, and refuses some pairs that never cross. The margin keeps +the rendered `f32` values strictly ordered. A failure is `control.automation.order`, once per lane +at the first failing piece, at the segment of the LPF entry (else the HPF entry) that defines its +value there. The rule reads only the model, so it also judges a static filter value under an +automated curve. + Session validation refuses what no rendering could honour, structurally and before any row is read: - **One entry per lane** (`automation.duplicate_lane`, at `$.automation[].target.lane`). Two diff --git a/tools/session-validator/tests/validate.rs b/tools/session-validator/tests/validate.rs index 3ecf87760..c153bd089 100644 --- a/tools/session-validator/tests/validate.rs +++ b/tools/session-validator/tests/validate.rs @@ -299,6 +299,23 @@ const MUTATIONS: &[(&str, &str, &str, usize, &str)] = &[ 4, "control.automation.target", ), + // The row also judges each segment (#1566, #1645): the fader ride written in `linear` units is + // `control.domain`, and the polarity flip as a `linear` ramp is `control.automation.shape` (a + // toggle row takes only `step`). + ( + "builtins-automation.json", + "\"end_value\": -3.0,\n \"unit\": \"db\"", + "\"end_value\": -3.0,\n \"unit\": \"linear\"", + 4, + "control.domain", + ), + ( + "builtins-automation.json", + "\"shape\": \"step\",\n \"start_sample\": \"480\"", + "\"shape\": \"linear\",\n \"start_sample\": \"480\"", + 4, + "control.automation.shape", + ), // Stored automation gives each lane one curve (#1626), checked structurally at stage 1: a // `left` trim ride added to the `both` fader's control, and a step then a segment that starts // away from the held value 40 samples later. From 8b3ae13ec1d580ad38d80a540e2070774ec737b2 Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 18:56:49 +0000 Subject: [PATCH 05/10] Batch 14a fold for #1645: a static HPF or LPF edit under filter automation meets the order rule Co-Authored-By: Claude Opus 5.5 --- ...iltin-automation-targets-at-preparation.md | 19 +++- crates/capi/src/runtime/live_tests.rs | 92 +++++++++++++++++++ crates/controls/src/automation.rs | 45 ++++++++- crates/controls/src/lib.rs | 5 +- crates/host-core/src/live_delta.rs | 23 ++++- crates/host-core/tests/live_delta.rs | 57 ++++++++++++ 6 files changed, 232 insertions(+), 9 deletions(-) diff --git a/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md b/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md index c89c81f01..163c96ce4 100644 --- a/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md +++ b/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md @@ -315,6 +315,19 @@ Checked against `12d4a4f9d`. evidence. - **Docs.** `docs/SESSION_SCHEMA_V1.md` names both codes, the shape rule and the curve order rule; the `author-session` skill's automation refusal table gains three rows at `prepare-effects`. -- **Not done here (successor, #1660):** a static HPF/LPF edit that crosses an automated curve - without changing the automation still classifies live, because the classifier's step 2 runs the - row rule only when the automation or activity changes. +- **Fold (batch 14a).** The gap the first pass found is closed here: a static HPF or LPF edit that + crosses an automated curve with the automation table unchanged classified live, so the committed + model could break the order rule. `controls` gains `filter_statics_changed_under_automation` + (a strip's static HPF or LPF bits differ and `next` automates either filter of that strip) and + `filter_under_automation` (an address is such a strip's HPF or LPF). The classifier's step 2 + (`crates/host-core/src/live_delta.rs`) also runs `automation_diagnostics` on `next` when the + first holds, and refuses with `LiveRebuild::AutomationTarget`, as the #1631 activity path does; + the fast path (`classify_rows`, used by the C ABI and the browser through `control-plane`) + answers `None` for a row the second names, so the full classifier judges it. Tests: + `a_static_filter_edit_that_crosses_an_automated_curve_needs_a_rebuild` (host-core `live_delta`; + red without the step-2 change: `Ok([("eq0", [], None)])` against `Err(AutomationTarget)`) and + `a_static_lpf_edit_that_crosses_an_automated_hpf_is_refused_before_any_ack` (capi; a + `SET_CONTROL` of the LPF to 500 Hz under an HPF ride to 900 Hz: red without the fix, and red + again with the step-2 change but without the fast-path fallback, both times `RESULT_OK` (0) + against `RESULT_COMPILE_REJECTED` (5); a move to 2 kHz commits live). #1660 keeps only the + classification of input-filter edits on an automated strip, not this refusal. diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index f516e9ab0..9921702c6 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -4630,6 +4630,98 @@ fn a_builtin_automation_the_rows_refuse_is_refused_before_any_ack() { } } +/// #1645 fold. On a playing engine whose `eq0` stores an HPF ride from 100 to 900 Hz under a static +/// 1 kHz LPF, a `SET_CONTROL` that moves the static LPF to 500 Hz (the automation table unchanged) +/// is refused with the compile rejection preparation gives (`control.automation.order`): the +/// revision, the committed model and the reliable lane do not move, no candidate is prepared, and +/// the next blocks are bit-identical to an engine that never received it. A move to 2 kHz commits +/// live. +/// +/// Red if the classifier (its fast path included) re-checks the order rule only when the +/// automation table changes: the edit commits live, acked, and its next rebuild refuses it. +#[test] +fn a_static_lpf_edit_that_crosses_an_automated_hpf_is_refused_before_any_ack() { + let ride = builtin_automation_edit( + 3, + session::AutomationShape::Linear, + session::ParameterUnit::Hz, + (100.0, 900.0), + ); + let document = with_model(&eq_session(48_000), |model| { + let builtins = &mut model.tracks[0].builtins; + for lane in [&mut builtins.left, &mut builtins.right] { + lane.hpf_hz = 0.0; + lane.lpf_hz = 1_000.0; + } + protocol::apply_session_edit(model, &ride).expect("the ride applies"); + }); + let mut rig = Rig::new(&document); + let mut untouched = Rig::new(&document); + for _ in 0..window(rig.latency(), 0, rig.quantum) { + assert_eq!(bits(&rig.step()), bits(&untouched.step()), "warm-up"); + } + let lpf = |value: f32| SessionEdit::SetControl { + expected_effect: None, + address: session::ControlAddress { + owner: session::Owner::Strip(StableId::parse("eq0").expect("strip")), + section: session::Section::Builtins, + control: 4, + lane: session::ParameterChannel::Both, + }, + value, + ramp_samples: None, + }; + + let refused = lpf(500.0); + let before = (refusal_state(&rig), rig.summary()); + assert_eq!( + rig.apply(core::slice::from_ref(&refused)), + crate::RESULT_COMPILE_REJECTED + ); + let mut rejected = parse_session_json(&document).expect("model"); + protocol::apply_session_edit(&mut rejected, &refused).expect("the edit applies to the model"); + let rejected = session::canonical_session_json(&rejected).expect("canonical rejected"); + let Err(failure) = compile_children(&rejected, limits()) else { + panic!("a static LPF under the HPF ride does not prepare") + }; + assert_eq!( + rig.last_error(), + failure.diagnostics, + "the refusal is preparation's diagnostic" + ); + assert!( + String::from_utf8_lossy(&failure.diagnostics).contains("control.automation.order"), + "{}", + String::from_utf8_lossy(&failure.diagnostics) + ); + assert_eq!( + (refusal_state(&rig), rig.summary()), + before, + "no revision, no candidate, no event" + ); + for _ in 0..3 { + let block = rig.block; + let expected = untouched.step(); + assert!( + expected.iter().any(|sample| *sample != 0.0), + "block {block}" + ); + assert_eq!(bits(&rig.step()), bits(&expected), "block {block}"); + } + + let (revision, _, epoch, _) = rig.summary(); + assert_eq!( + rig.apply(core::slice::from_ref(&lpf(2_000.0))), + crate::RESULT_OK + ); + let (after, _, after_epoch, pending) = rig.summary(); + assert_eq!( + (after, after_epoch, pending), + (revision + 1, epoch, 0), + "an ordered static LPF commits live: one revision, no candidate" + ); +} + // Issue #1552: the control list on the C ABI. /// Two tracks (each with an insert and the session's console slots), a submix that a send feeds, a diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index 20f8940a1..c3218d9d3 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -5,7 +5,7 @@ use automation::{Segment, Shape, value_at}; use effect_contract::ParameterUnit; use session::{ Automation, AutomationSegment, AutomationShape, ControlAddress, Owner, Section, SessionModel, - StripRef, + StableId, StripRef, }; use crate::ControlKind; @@ -160,6 +160,49 @@ pub fn automation_diagnostics(model: &SessionModel, sample_rate: u32) -> Vec bool { + address.section == Section::Builtins + && (address.control == HPF_HZ || address.control == LPF_HZ) + && matches!(&address.owner, Owner::Strip(strip) if has_filter_automation(model, strip)) +} + +/// Whether `model` stores automation on the HPF or the LPF of strip `strip` (either lane). +fn has_filter_automation(model: &SessionModel, strip: &StableId) -> bool { + model.automation.iter().any(|automation| { + let target = &automation.target; + matches!(&target.owner, Owner::Strip(owner) if owner == strip) + && target.section == Section::Builtins + && (target.control == HPF_HZ || target.control == LPF_HZ) + }) +} + +/// Whether a static HPF or LPF value differs between `current` and `next` on a strip that `next` +/// automates either filter of (#1645 fold). The order rule reads a non-automated side's static +/// value, so such an edit can break it with the automation table unchanged: a classifier re-runs +/// [`automation_diagnostics`] on `next` when this holds. Strips are compared in order; a model +/// whose strips differ is a structural change, which the caller refuses first. +#[must_use] +pub fn filter_statics_changed_under_automation( + current: &SessionModel, + next: &SessionModel, +) -> bool { + current.strips().zip(next.strips()).any(|(before, after)| { + let moved = [ + (before.builtins.left.hpf_hz, after.builtins.left.hpf_hz), + (before.builtins.left.lpf_hz, after.builtins.left.lpf_hz), + (before.builtins.right.hpf_hz, after.builtins.right.hpf_hz), + (before.builtins.right.lpf_hz, after.builtins.right.lpf_hz), + ] + .into_iter() + .any(|(old, new)| old.to_bits() != new.to_bits()); + moved && has_filter_automation(next, after.id) + }) +} + /// Whether the row takes the segment's shape (#1645 D1, the shape rule; the values are already in /// the row's domain). fn shape_fits(descriptor: &ControlDescriptor, segment: &AutomationSegment) -> bool { diff --git a/crates/controls/src/lib.rs b/crates/controls/src/lib.rs index 42a5b55c2..463f8c53d 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -44,7 +44,10 @@ mod tables; mod vca; mod view; -pub use automation::{ControlDiagnostic, automation_diagnostics}; +pub use automation::{ + ControlDiagnostic, automation_diagnostics, filter_statics_changed_under_automation, + filter_under_automation, +}; pub use descriptor::{ AutomationRule, Choice, ControlDescriptor, ControlScope, DomainRule, Lanes, Motion, check, }; diff --git a/crates/host-core/src/live_delta.rs b/crates/host-core/src/live_delta.rs index 574e6a717..ca8f46222 100644 --- a/crates/host-core/src/live_delta.rs +++ b/crates/host-core/src/live_delta.rs @@ -264,9 +264,11 @@ pub use crate::prepare::LiveRebuild; /// canonical JSON error is structural too. /// 2. [`LiveRebuild::AutomationTarget`] if `current.automation != next.automation` or a row's /// activity changed ([`controls::activity_changed`]: a strip's `matrix_mode` switch flips which -/// of its pan and matrix rows are active, #1631) and [`controls::automation_diagnostics`] -/// refuses one of `next`'s automations (decision 15 E1, #1335 D4, #1566 D6). An unchanged -/// automation table over unchanged activity is never re-checked: it was prepared. An accepted +/// of its pan and matrix rows are active, #1631) or a static HPF or LPF moved on a strip that +/// automates either filter ([`controls::filter_statics_changed_under_automation`]: the order +/// rule reads the static side, #1645) and [`controls::automation_diagnostics`] refuses one of +/// `next`'s automations (decision 15 E1, #1335 D4, #1566 D6). An unchanged automation table +/// over unchanged activity and unchanged filter statics is never re-checked: it was prepared. An accepted /// change stays live with no records, as #1260 D2 made it. /// 3. With the structure equal, the two models list the same controls in the same order. For each /// control whose value bits differ ([`controls::changed_controls`]): a row that is @@ -389,7 +391,9 @@ pub fn classify_live_delta_with_monitor<'a>( drop(masked); // Step 2. - if (current.automation != next.automation || controls::activity_changed(current, next)) + if (current.automation != next.automation + || controls::activity_changed(current, next) + || controls::filter_statics_changed_under_automation(current, next)) && !controls::automation_diagnostics(next, next.sample_rate_hz).is_empty() { return Err(LiveRebuild::AutomationTarget); @@ -478,6 +482,8 @@ pub fn classify_live_delta_with_monitor<'a>( /// - `rows` is `None` (a structural edit, or any other edit); /// - a named row moves more than itself (`controls::changed_rows`: the session's rows, a strip's /// `matrix_mode`), does not resolve in both models, or is not live; +/// - a named row is a strip's HPF or LPF and the strip automates either filter: the order rule +/// reads the static value (#1645), and the full classifier's step 2 judges it; /// - the effective values of the strips and sends the rows reach cannot be built locally; /// - a step of the lowering refuses: the full classifier then decides which refusal wins. /// @@ -569,6 +575,15 @@ fn classify_rows<'a>( return None; } + // A static filter under filter automation can break the order rule (#1645): step 2 of the full + // classifier judges it. The edits leave the automation table as committed. + if rows + .iter() + .any(|address| controls::filter_under_automation(current, address)) + { + return None; + } + // The changed rows, in the control list's order, each row once. let mut ordered = Vec::with_capacity(rows.len()); for address in rows { diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index f3b636b8f..497954262 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -1273,6 +1273,63 @@ fn an_automation_the_builtin_rows_refuse_needs_a_rebuild() { ); } +/// #1645 fold. A delta that only moves a static LPF under a stored HPF ride (the automation table +/// unchanged) routes to the rebuild when the order rule refuses `next`: an LPF of 500 Hz under a +/// ride to 900 Hz. An LPF of 2 kHz, an LPF edit on a strip with no filter automation, and the same +/// crossing edit on a strip whose ride is on the fader stay live. +/// +/// Red if step 2 re-checks the row rule only when the automation table or a row's activity +/// changes: the edit commits live, acked, and its next rebuild refuses it. +#[test] +fn a_static_filter_edit_that_crosses_an_automated_curve_needs_a_rebuild() { + let hpf_ride = |model: &SessionModel| { + let mut ride = fader_ride(model); + ride.id = id("hpf-ride"); + ride.target.control = 3; + ride.segments[0].unit = ParameterUnit::Hz; + ride.segments[0].start_value = 100.0; + ride.segments[0].end_value = 900.0; + ride + }; + let lpf_both = |hz: f32| { + move |next: &mut SessionModel| { + next.tracks[0].builtins.left.lpf_hz = hz; + next.tracks[0].builtins.right.lpf_hz = hz; + } + }; + let mut declared = two_tracks(); + declared.tracks[0].builtins.left.lpf_hz = 1_000.0; + declared.tracks[0].builtins.right.lpf_hz = 1_000.0; + let mut automated = declared.clone(); + let ride = hpf_ride(&automated); + automated.automation.push(ride); + let automated = normalized(&automated); + assert_eq!( + classify(&automated, lpf_both(500.0)), + Err(LiveRebuild::AutomationTarget), + "a static LPF below the HPF ride" + ); + assert_eq!( + classify(&automated, |next| { + next.tracks[0].builtins.right.lpf_hz = 500.0; + }), + Err(LiveRebuild::AutomationTarget), + "one lane is enough" + ); + assert!( + classify(&automated, lpf_both(2_000.0)).is_ok(), + "a static LPF above the ride" + ); + let mut fader = declared; + let ride = fader_ride(&fader); + fader.automation.push(ride); + let fader = normalized(&fader); + assert!( + classify(&fader, lpf_both(500.0)).is_ok(), + "no filter automation: the static rule alone" + ); +} + /// The model-only edits: #1260 D1's four and #1365 D4's `control_smoothing`. fn model_only_edits() -> [(&'static str, Edit); 5] { [ From 5ef2c81d8d44fadfa664dade0d966550dbeb3670 Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 18:59:19 +0000 Subject: [PATCH 06/10] Designate the browser master strip after boot through one core monitor row (#1683) Co-Authored-By: Claude Opus 5.5 --- ...ate-the-browser-master-strip-after-boot.md | 77 ++++++++ crates/control-plane/src/compile.rs | 20 +- crates/control-plane/src/control.rs | 10 + crates/controls/src/effective.rs | 104 ++++++++-- crates/controls/src/lib.rs | 2 +- crates/controls/src/registry.rs | 28 +-- crates/controls/src/strip.rs | 52 +++-- crates/controls/tests/list.rs | 13 +- crates/controls/tests/monitor.rs | 126 +++++++++++- crates/host-core/src/live_delta.rs | 25 ++- crates/host-core/src/prepare.rs | 49 ++++- crates/host-core/tests/effect_observation.rs | 7 +- crates/host-core/tests/strip_meters.rs | 75 ++++++++ crates/protocol/src/controller/tests.rs | 6 +- crates/protocol/src/lib.rs | 2 +- crates/protocol/src/model.rs | 8 +- hosts/host-web/src/lib.rs | 35 +++- hosts/host-web/src/tests.rs | 181 ++++++++++++++++++ hosts/host-web/tests/browser-v1/expected.json | 4 +- hosts/host-web/tests/control_parity.rs | 99 ++++++++++ .../host-web/tests/fixtures/control-list.json | 4 + .../miso-engine-v1-audio-worklet-host.d.ts | 7 +- .../miso-engine-v1-parameter-metadata.json | 1 + sdk/src/browser/shipped-host.d.ts | 7 +- sdk/src/generated/control-builders.ts | 19 ++ sdk/src/generated/controls.ts | 35 ++++ 26 files changed, 919 insertions(+), 77 deletions(-) diff --git a/.github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md b/.github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md index d744b11df..b047f6d09 100644 --- a/.github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md +++ b/.github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md @@ -39,3 +39,80 @@ that must name a strip that exists at boot. ## Size Half a day. + +## Decision record: #1683 (batch 14a) + +**Decisions taken (implementer's proposal, for the coordinator's ruling).** + +- **Where it lives: a core-owned monitor row.** Every strip (track and submix) has an own row + `master` (control ID 4, toggle, `scope: monitor`, family `setting`, fixed motion, not + automatable). Like `solo` it is host-local: held in the control plane's monitor overlay + (`controls::MonitorOverlay::master`), never saved, never in a snapshot, and a write is a + monitor-only `SET_CONTROL` that spends no revision and replies path `monitor`. It is held by + strip ID, so a strip added or removed before the master never moves it. At most one strip is + the master: designating one ends the other's designation; clearing a strip that is not the + master changes nothing. Both hosts reach it through the same `SESSION_TRANSACTION_APPLY`; the + SDK's generated builders gain `strip.master(enabled)`. +- **A strip that does not exist yet: refused before commit.** The write is checked by + `controls::check_monitor` like any address; an unknown strip is `AddressUnknown`. A + structural transaction that removes the master strip ends the designation + (`MonitorOverlay::retain_strips_of`), as a removed strip keeps no solo. + +**What shipped.** + +- `controls`: `MonitorOverlay` gains `master`, `is_master`, `set_master`, `same_solo`; + `MonitorWrite` gains `row: MonitorRow { Solo, Master }` and its `soloed` field is now `on`; + `MonitorOverlay::after` applies each write to its row, and only solo writes choose the ramp. +- `protocol`: a later monitor write replaces an earlier one of the same strip *and row*. +- `host-core`: the classifiers compare solos only for the effective values (a master change + moves none) and mark the owners whose master row changed, so the catalog refreshes them. The + preparation resolves the master once, before allocating: a first plan from + `HostLiveControlRequest::master_track` (the browser's boot word, by index, refused + `host.observation.master_track` when it names no strip, as before); a successor from + `SuccessorBase::monitor.next` by ID. A successor request that names an index is refused with + the same code. The boot catalog lists the boot word's designation. +- `control-plane`: the boot plan's `master_track` seeds the session's overlay + (`PreparedRuntime::master_track`); `SessionState::master_strip()` reads it. +- `host-web`: the boot word designates the first plan only (successor requests carry `None`); + after every successful control command `ReadyOwnership::sync_master` resolves the core's + master by ID in the rendering plan's tables and in each replacement's that waits for adoption, + and writes `PlanShape::master_track` and the meter header's `master_track_plus_one`. The meter + fold is unchanged. + +**Departures.** None from frozen text. The boot word `liveControlMasterTrackPlusOne` stays: it +seeds the core's overlay, so a boot designation and a later `SET_CONTROL` are one state. It also +fixes a latent defect: a successor plan no longer reuses the boot index (after a strip was added +before the master, it named another strip, or refused the rebuild). + +**Gates.** + +1. Browser: `structural_apply::a_master_designated_after_an_empty_boot_reads_as_a_boot_designation` + (`hosts/host-web/src/tests.rs`): empty boot refuses the boot word; a rebuild adds `t0` + (compressor); `SET_CONTROL master` on `t0` applies on path `monitor` at the same revision; + the header names strip 1 and the master word is `t0`'s own, bit for bit; a later rebuild adds + `a0` before it and the master follows `t0` to strip 2; an unknown strip is refused; clearing + reads absent. C ABI parity row: + `both_hosts_designate_the_master_strip_after_an_empty_boot_alike` + (`hosts/host-web/tests/control_parity.rs`): same verdicts, control list and state pages and + snapshots on both hosts for designate, move, refuse and clear. + Core: `the_master_designation_is_one_monitor_row_held_by_strip_id` + (`crates/controls/tests/monitor.rs`), `a_successor_takes_its_master_from_the_overlay_by_strip_id` + (`crates/host-core/tests/strip_meters.rs`); `list_yields_every_expected_address_once_in_ruled_order` + updated for the new row. +2. Suites (all green): `cargo test -p controls`, `-p protocol -p control-plane --all-features`, + `-p host-core --all-features`, `-p host-web --all-features`, `-p capi --all-features`, + `-p conformance`; `check-sdk-generated.sh`, `check-sdk-types.sh`; realtime policy ok (149 + regions); clippy `-D warnings` and fmt on the touched crates. The real-browser qualification + was not run here (no browser matrix in this worktree); the boot word it uses is unchanged. + +**Moved pins (measured).** + +- `hosts/host-web/tests/browser-v1/expected.json`, simd128 `initialStatus` and + `beforeDisposeStatus` `memoryBytes`: 2,818,048 -> 2,883,584 (one 64 KiB page), reason #1683 + (one more own row per strip in the catalog and the overlay's master). Measured with + `check-browser-expected-resources.py` on this tree; the base `616106095` measures 2,818,048. +- `hosts/host-web/tests/fixtures/control-list.json`: blessed (`MISO_ENGINE_BLESS_FIXTURES=1`), four + `master` rows added, one per strip of the parity session. +- SDK regenerated: `sdk/assets/miso-engine-v1-parameter-metadata.json`, + `sdk/src/generated/controls.ts`, `sdk/src/generated/control-builders.ts`. No wire change, so no + schema hash moved. diff --git a/crates/control-plane/src/compile.rs b/crates/control-plane/src/compile.rs index a94323c78..21031c356 100644 --- a/crates/control-plane/src/compile.rs +++ b/crates/control-plane/src/compile.rs @@ -25,6 +25,9 @@ pub(crate) struct PreparedRuntime { pub(crate) inventory: PlanStateInventory, pub(crate) control_catalog: ControlCatalog, pub(crate) capi: CapiResources, + /// The designated master strip the plan was prepared with, as a strip index (#1683): a first + /// plan's seeds the session's monitor overlay; a successor's is its overlay's own. + pub(crate) master_track: Option, } /// A zeroed byte buffer of exactly `bytes` bytes, allocated fallibly. @@ -931,6 +934,7 @@ pub(crate) fn prepare_runtime( effect_controls, route_controls, jump_lengths, + master_track, .. } = handles; // #1639 D1: every prepared plan has a jump-length cell, so a handle set without its writer is @@ -1032,6 +1036,7 @@ pub(crate) fn prepare_runtime( inventory: prepared.inventory, control_catalog: prepared.control_catalog, capi, + master_track, }) } @@ -1075,6 +1080,18 @@ where &live_values_superseded, 0, )?; + // #1683: the boot plan's designated master (the browser's boot word) seeds the overlay, so the + // control list, a later write of the row and every successor start from the same designation. + let mut monitor = controls::MonitorOverlay::new(); + if let Some(strip) = runtime.master_track.and_then(|index| { + store + .compiled() + .normalized_model() + .strips() + .nth(index as usize) + }) { + monitor.set_master(strip.id, true); + } let control_bytes = usize::try_from(limits.maximum_control_frame_bytes) .map_err(|_| failure(ResourceFault::Platform))?; @@ -1110,6 +1127,7 @@ where inventory, control_catalog, capi, + master_track: _, } = runtime; // #1314 D1, #1502 D5: the initial plan carries the store's initial revision, published on its // own gate, and the watermark starts there. @@ -1198,7 +1216,7 @@ where render_diagnostic_head: 0, render_diagnostic_len: 0, protocol_reliable_pending: false, - monitor: controls::MonitorOverlay::new(), + monitor, observation: controls::ObservationBindings::new(), observation_pending: Box::default(), observation_offsets: Box::default(), diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index c543d6daa..17cae137a 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -1696,6 +1696,7 @@ where control_catalog: candidate_catalog, capi: prospective_capi, revision_gate, + master_track: _, } = prepared_runtime; let mut candidate_provider = ProviderEpoch::candidate( sources, @@ -2957,6 +2958,15 @@ where } } + /// The designated master strip of the newest commit (#1683): the strip whose `master` + /// monitor row is on, or `None`. Host-local, never saved; the boot word seeds it and a + /// `SET_CONTROL` of the row sets or changes it. A host that reports a master reading resolves + /// it by ID in each plan it holds. + #[must_use] + pub fn master_strip(&self) -> Option<&session::StableId> { + self.monitor.master() + } + /// The taps the host has armed (#1584 D6a), for a test or an adapter that reports them. #[must_use] pub fn observation_bindings(&mut self) -> &controls::ObservationBindings { diff --git a/crates/controls/src/effective.rs b/crates/controls/src/effective.rs index 775a2247d..f8c077dfb 100644 --- a/crates/controls/src/effective.rs +++ b/crates/controls/src/effective.rs @@ -22,19 +22,29 @@ use crate::view::ModelView; /// The host-local monitor values. They are not part of the session: they never reach a snapshot. /// -/// In this slice it holds only the soloed strips, as a sorted set of IDs. A soloed ID that names a -/// submix or no strip has no effect: a submix is solo-safe. +/// It holds the soloed strips, as a sorted set of IDs, and the designated master strip (#1683). A +/// soloed ID that names a submix or no strip has no effect: a submix is solo-safe. +/// +/// The master is a designation, not a discovery (issue #143 D6): the strip whose gain reduction a +/// host reports as the master's. It names a strip by ID, so a strip added or removed before it +/// keeps it, and at most one strip is the master. It moves no render value. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct MonitorOverlay { /// Sorted, with no duplicate. soloed: Vec, + /// The designated master strip, or `None`. + master: Option, } impl MonitorOverlay { - /// An overlay that solos nothing. `const`, so a host may keep one in a `static`. + /// An overlay that solos nothing and designates no master. `const`, so a host may keep one in + /// a `static`. #[must_use] pub const fn new() -> Self { - Self { soloed: Vec::new() } + Self { + soloed: Vec::new(), + master: None, + } } /// An overlay that solos exactly the named strips. @@ -43,7 +53,10 @@ impl MonitorOverlay { let mut soloed: Vec = ids.into_iter().collect(); soloed.sort_unstable(); soloed.dedup(); - Self { soloed } + Self { + soloed, + master: None, + } } /// The soloed IDs, sorted. @@ -69,43 +82,100 @@ impl MonitorOverlay { } } - /// Drop every soloed ID that names no strip of `model`: a removed strip keeps no solo. + /// The designated master strip, or `None` (#1683). + #[must_use] + pub fn master(&self) -> Option<&StableId> { + self.master.as_ref() + } + + /// Whether `id` is the designated master. Allocation-free. + #[must_use] + pub fn is_master(&self, id: &StableId) -> bool { + self.master.as_ref() == Some(id) + } + + /// Designate `id` the master (`on`), which ends any other strip's designation, or end its + /// designation (`!on`); ending the designation of a strip that is not the master changes + /// nothing. + pub fn set_master(&mut self, id: &StableId, on: bool) { + if on { + self.master = Some(id.clone()); + } else if self.is_master(id) { + self.master = None; + } + } + + /// Whether the two overlays solo the same strips: a change of the master alone moves no + /// effective value. + #[must_use] + pub fn same_solo(&self, other: &Self) -> bool { + self.soloed == other.soloed + } + + /// Drop every soloed ID, and the master, that names no strip of `model`: a removed strip keeps + /// no solo and is no longer the master. pub fn retain_strips_of(&mut self, model: &SessionModel) { self.soloed .retain(|id| crate::registry::strip_index(model, id).is_some()); + if self + .master + .as_ref() + .is_some_and(|id| crate::registry::strip_index(model, id).is_none()) + { + self.master = None; + } } /// The overlay after `writes`, applied in order, on a session whose strips are `next`'s, and /// the ramp the change carries (decision 17, C6 step 4, #1559 D7). /// - /// The ramp is that of the last write whose strip's solo differs between `self` and the - /// result, and `None` (the session's `mute_ms`) when no write changed anything or the last - /// such write gave no ramp. A strip `next` no longer has is dropped from the result. + /// The ramp is that of the last solo write whose strip's solo differs between `self` and the + /// result, and `None` (the session's `mute_ms`) when no solo write changed anything or the last + /// such write gave no ramp; a master write has no ramp, since it moves no render value. A strip + /// `next` no longer has is dropped from the result. #[must_use] pub fn after(&self, writes: &[MonitorWrite], next: &SessionModel) -> MonitorChange { let mut overlay = self.clone(); for write in writes { - overlay.set_soloed(&write.strip, write.soloed); + match write.row { + MonitorRow::Solo => overlay.set_soloed(&write.strip, write.on), + MonitorRow::Master => overlay.set_master(&write.strip, write.on), + } } overlay.retain_strips_of(next); let ramp = writes .iter() .rev() - .find(|write| overlay.is_soloed(&write.strip) != self.is_soloed(&write.strip)) + .find(|write| { + write.row == MonitorRow::Solo + && overlay.is_soloed(&write.strip) != self.is_soloed(&write.strip) + }) .and_then(|write| write.ramp); MonitorChange { overlay, ramp } } } -/// One validated write to a monitor row (solo): the strip, the new state and the explicit ramp -/// length of the write, in samples. See [`check_monitor`](crate::check_monitor). +/// Which monitor row a [`MonitorWrite`] sets. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MonitorRow { + /// The strip's `solo` (#1559). + Solo, + /// The strip's `master` designation (#1683). + Master, +} + +/// One validated write to a monitor row (solo or master): the strip, the row, the new state and +/// the explicit ramp length of the write, in samples. See [`check_monitor`](crate::check_monitor). #[derive(Clone, Debug, Eq, PartialEq)] pub struct MonitorWrite { - /// The strip whose solo the write sets. + /// The strip whose row the write sets. pub strip: StableId, - /// Whether the strip is soloed after the write. - pub soloed: bool, - /// The write's own ramp length in samples; `None` is the session's `mute_ms`. + /// The row it sets. + pub row: MonitorRow, + /// Whether the row is on after the write: the strip soloed, or designated the master. + pub on: bool, + /// The write's own ramp length in samples; `None` is the session's `mute_ms`. A master row + /// takes none. pub ramp: Option, } diff --git a/crates/controls/src/lib.rs b/crates/controls/src/lib.rs index 42a5b55c2..f3b34aadf 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -58,7 +58,7 @@ 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, - MonitorOverlay, MonitorWrite, any_solo, any_solo_in, effective, effective_follow, + MonitorOverlay, MonitorRow, MonitorWrite, any_solo, any_solo_in, effective, effective_follow, effective_gate, effective_into, effective_strip, solo_term, }; pub use error::ControlError; diff --git a/crates/controls/src/registry.rs b/crates/controls/src/registry.rs index 8cd797a0f..95848cf0c 100644 --- a/crates/controls/src/registry.rs +++ b/crates/controls/src/registry.rs @@ -5,7 +5,7 @@ use session::{SessionModel, StableId, StripRef}; use crate::Family; use crate::descriptor::{ControlDescriptor, ControlScope, Lanes, Motion, check}; use crate::effective::{ - Effective, EffectiveSource, EffectiveStrip, MonitorOverlay, MonitorWrite, effective, + Effective, EffectiveSource, EffectiveStrip, MonitorOverlay, MonitorRow, MonitorWrite, effective, }; use crate::error::ControlError; use crate::lane::{Side, both_sides, toggle}; @@ -205,7 +205,7 @@ pub fn owner_rows_in( &model.console, &strip, &effective.strip(index), - overlay.is_soloed(strip.id), + overlay, sample_rate, ); } @@ -326,8 +326,8 @@ pub(crate) enum Source { StripDerived(fn(&EffectiveStrip, Side) -> f32), /// A route's derived row: computed from the route's effective gate. RouteDerived(fn([bool; 2], Side) -> f32), - /// Held by the host (solo): the overlay's value. - Monitor, + /// Held by the host (solo, master): the overlay's value of that row. + Monitor(MonitorRow), } /// One row found by address: its descriptor and where its value comes from. Built without @@ -392,8 +392,8 @@ pub fn get( let side = Side::of(address.lane); Ok(match resolved.source { Source::Stored(values) => side.pick(values[0], values[1]), - Source::Monitor => match &address.owner { - Owner::Strip(id) => toggle(overlay.is_soloed(id)), + Source::Monitor(row) => match &address.owner { + Owner::Strip(id) => toggle(crate::strip::monitor_value(overlay, row, id)), _ => 0.0, }, // Only a derived row pays for the effective values. @@ -460,7 +460,7 @@ pub fn set( // `check` has refused a derived row, so only a monitor row or a stored row is left. let committed = match resolved.source { Source::Stored(committed) => committed, - Source::Monitor => return Err(ControlError::Monitor), + Source::Monitor(_) => return Err(ControlError::Monitor), Source::StripDerived(_) | Source::RouteDerived(_) => return Err(ControlError::ReadOnly), }; let unchanged = descriptor @@ -475,10 +475,12 @@ pub fn set( write(model, address, descriptor.lanes, value) } -/// Validate a write to a monitor row (solo) and say what it sets (decision 17, C8, #1559 D3). +/// Validate a write to a monitor row (solo, or the master designation of #1683) and say what it +/// sets (decision 17, C8, #1559 D3). /// -/// The address is looked up and the value checked exactly as [`set`] does: an unknown strip, a -/// submix (it has no solo row, a submix is solo-safe) or an unknown control is +/// The address is looked up and the value checked exactly as [`set`] does: an unknown strip (a +/// master designation that names a strip the session does not have is refused here, before any +/// commit), a submix's solo (it has no solo row, a submix is solo-safe) or an unknown control is /// [`ControlError::AddressUnknown`], a lane other than `both` is [`ControlError::Lane`], a value /// other than exactly `0` or `1` is [`ControlError::Domain`]. Nothing is written: the overlay is /// the host's. `Ok(None)` for an address whose row is a model row, which [`set`] writes; its @@ -502,9 +504,13 @@ pub fn check_monitor( let Owner::Strip(strip) = &address.owner else { return Err(ControlError::AddressUnknown); }; + let Source::Monitor(row) = resolved.source else { + return Err(ControlError::AddressUnknown); + }; Ok(Some(MonitorWrite { strip: strip.clone(), - soloed: crate::lane::is_on(value), + row, + on: crate::lane::is_on(value), ramp, })) } diff --git a/crates/controls/src/strip.rs b/crates/controls/src/strip.rs index a82a22b63..bd1ff28b9 100644 --- a/crates/controls/src/strip.rs +++ b/crates/controls/src/strip.rs @@ -12,7 +12,7 @@ use crate::descriptor::{ AutomationExclusion, AutomationRule, BASE, Choice, ControlDescriptor, Lanes, Motion, }; use crate::effect_rows::{self, Instance}; -use crate::effective::EffectiveStrip; +use crate::effective::{EffectiveStrip, MonitorOverlay, MonitorRow}; use crate::error::ControlError; use crate::lane::Side; use crate::lane::{is_on, toggle}; @@ -26,6 +26,8 @@ pub(crate) const MATRIX_MODE: u32 = 1; pub(crate) const SOLO: u32 = 2; /// The own-row ID of `pan_window_samples`. pub(crate) const PAN_WINDOW_SAMPLES: u32 = 3; +/// The own-row ID of `master` (#1683). +pub(crate) const MASTER: u32 = 4; /// The own-row ID of `effective_fader_db`. pub(crate) const EFFECTIVE_FADER_DB: u32 = 64; /// The own-row ID of `effective_mute`. @@ -63,7 +65,7 @@ pub(crate) enum StripValue { read: fn(&EffectiveStrip, Side) -> f32, }, /// Held by the host, not the model: the overlay writes it and the row reads the overlay. - Monitor, + Monitor(MonitorRow), } /// One row of a strip's own table. @@ -76,7 +78,7 @@ pub(crate) struct StripRow { } /// A strip's own rows, in listing order. Never reuse an ID. -pub(crate) static ROWS: [StripRow; 5] = [ +pub(crate) static ROWS: [StripRow; 6] = [ StripRow { id: MATRIX_MODE, on_submix: true, @@ -114,7 +116,7 @@ pub(crate) static ROWS: [StripRow; 5] = [ family: Family::StripLevel, ..BASE }, - value: StripValue::Monitor, + value: StripValue::Monitor(MonitorRow::Solo), }, StripRow { id: PAN_WINDOW_SAMPLES, @@ -136,6 +138,24 @@ pub(crate) static ROWS: [StripRow; 5] = [ }, }, }, + // #1683: the designated master strip, host-local like solo (a monitor row, never saved). On + // a submix too: a bus whose strip carries a limiter can be the master (issue #1208 D3). At + // most one strip is the master; designating one ends the other's designation. It moves no + // render value, so it has no ramp and no family that render reads. + StripRow { + id: MASTER, + on_submix: true, + descriptor: ControlDescriptor { + name: "master", + display_name: "Master", + kind: ControlKind::Toggle, + taper: Taper::Stepped, + lattice: ParameterLattice::indices(), + scope: ControlScope::Monitor, + ..BASE + }, + value: StripValue::Monitor(MonitorRow::Master), + }, StripRow { id: EFFECTIVE_FADER_DB, on_submix: true, @@ -265,11 +285,11 @@ pub(crate) fn push_rows( console: &Console, strip: &StripRef<'_>, effective: &EffectiveStrip, - soloed: bool, + overlay: &MonitorOverlay, sample_rate: u32, ) { let owner = Owner::Strip(strip.id.clone()); - push_own_rows(out, strip, effective, soloed, false); + push_own_rows(out, strip, effective, overlay, false); for row in builtin_rows::rows() { let descriptor = builtin_rows::descriptor(row, sample_rate, strip.matrix_or_pan); push_control( @@ -305,7 +325,7 @@ fn push_own_rows( out: &mut Vec, strip: &StripRef<'_>, effective: &EffectiveStrip, - soloed: bool, + overlay: &MonitorOverlay, derived_only: bool, ) { let owner = Owner::Strip(strip.id.clone()); @@ -323,7 +343,7 @@ fn push_own_rows( |side| match &row.value { StripValue::Stored { read, .. } => read(strip, side), StripValue::Derived { read } => read(effective, side), - StripValue::Monitor => toggle(soloed), + StripValue::Monitor(monitor) => toggle(monitor_value(overlay, *monitor, strip.id)), }, ); } @@ -335,7 +355,15 @@ pub(crate) fn push_derived_rows( strip: &StripRef<'_>, effective: &EffectiveStrip, ) { - push_own_rows(out, strip, effective, false, true); + push_own_rows(out, strip, effective, &MonitorOverlay::new(), true); +} + +/// Whether the monitor row `row` of strip `id` is on under `overlay`. +pub(crate) fn monitor_value(overlay: &MonitorOverlay, row: MonitorRow, id: &StableId) -> bool { + match row { + MonitorRow::Solo => overlay.is_soloed(id), + MonitorRow::Master => overlay.is_master(id), + } } /// Find one row of strip `id` (a track or a submix) by section and control, as [`push_rows`] would @@ -367,9 +395,9 @@ pub(crate) fn resolve( descriptor: row.descriptor, source: Source::StripDerived(*read), }, - StripValue::Monitor => Resolved { + StripValue::Monitor(monitor) => Resolved { descriptor: row.descriptor, - source: Source::Monitor, + source: Source::Monitor(*monitor), }, }) } @@ -443,7 +471,7 @@ pub(crate) fn write_own( .. }) => Err(ControlError::ReadOnly), Some(StripRow { - value: StripValue::Monitor, + value: StripValue::Monitor(_), .. }) => Err(ControlError::Monitor), None => Err(ControlError::AddressUnknown), diff --git a/crates/controls/tests/list.rs b/crates/controls/tests/list.rs index 690120ae2..506260a7a 100644 --- a/crates/controls/tests/list.rs +++ b/crates/controls/tests/list.rs @@ -19,12 +19,19 @@ enum Listed { } use Listed::{PerLane, Shared}; -/// A submix has no solo row (it is solo-safe). -const SUBMIX_OWN: [(u32, Listed); 4] = [(1, Shared), (3, Shared), (64, PerLane), (65, PerLane)]; -const STRIP_OWN: [(u32, Listed); 5] = [ +/// A submix has no solo row (it is solo-safe); every strip has the master row (#1683). +const SUBMIX_OWN: [(u32, Listed); 5] = [ + (1, Shared), + (3, Shared), + (4, Shared), + (64, PerLane), + (65, PerLane), +]; +const STRIP_OWN: [(u32, Listed); 6] = [ (1, Shared), (2, Shared), (3, Shared), + (4, Shared), (64, PerLane), (65, PerLane), ]; diff --git a/crates/controls/tests/monitor.rs b/crates/controls/tests/monitor.rs index de5b70a9a..a615b89ff 100644 --- a/crates/controls/tests/monitor.rs +++ b/crates/controls/tests/monitor.rs @@ -4,7 +4,10 @@ mod common; use common::{RATE, fixture, id, strip_builtin, strip_own}; -use controls::{ControlError, Lane, MonitorOverlay, MonitorWrite, check_monitor, effective, set}; +use controls::{ + ControlError, Lane, MonitorOverlay, MonitorRow, MonitorWrite, Owner, Section, check_monitor, + effective, set, +}; const SOLO: u32 = 2; const FADER_DB: u32 = 5; @@ -12,7 +15,8 @@ const FADER_DB: u32 = 5; fn write(strip: &str, soloed: bool, ramp: Option) -> MonitorWrite { MonitorWrite { strip: id(strip), - soloed, + row: MonitorRow::Solo, + on: soloed, ramp, } } @@ -164,3 +168,121 @@ fn set_soloed_keeps_the_set_sorted_and_the_effective_mutes_follow() { "a submix is solo-safe" ); } + +const MASTER: u32 = 4; + +fn master(strip: &str, on: bool) -> MonitorWrite { + MonitorWrite { + strip: id(strip), + row: MonitorRow::Master, + on, + ramp: None, + } +} + +/// #1683: the master designation is a monitor row on every strip, a submix included, checked like +/// a solo by its row; it takes no ramp, a strip the session does not have is refused before any +/// commit, and the overlay holds one master at most, by ID, which `get` and the list read back +/// and which a solo-only reader never sees. +/// +/// Red if a submix cannot be designated, a ramp or a strip the session lacks is accepted, a second +/// designation leaves two masters, ending another strip's designation clears the master, a removed +/// master survives, a master write gives the solo's records a ramp, or the row reads the solo. +#[test] +fn the_master_designation_is_one_monitor_row_held_by_strip_id() { + let model = fixture(); + let check_master = |strip: &str, value, ramp| { + check_monitor( + &model, + &strip_own(strip, MASTER, Lane::Both), + value, + ramp, + RATE, + ) + }; + assert_eq!( + check_master("bus", 1.0, None), + Ok(Some(master("bus", true))) + ); + assert_eq!( + check_master("vocal", 0.0, None), + Ok(Some(master("vocal", false))) + ); + assert_eq!( + check_master("vocal", 1.0, Some(48)), + Err(ControlError::RampFixed), + "a designation moves no render value, so it takes no ramp" + ); + assert_eq!( + check_master("nobody", 1.0, None), + Err(ControlError::AddressUnknown), + "a strip the session does not have is refused before commit" + ); + assert_eq!(check_master("vocal", 0.5, None), Err(ControlError::Domain)); + + let none = MonitorOverlay::new(); + let change = none.after( + &[ + master("vocal", true), + write("guitar", true, Some(480)), + master("bus", true), + master("vocal", false), + ], + &model, + ); + assert_eq!( + change.overlay.master(), + Some(&id("bus")), + "one master, the last named" + ); + assert!(!change.overlay.is_master(&id("vocal"))); + assert_eq!(change.overlay.soloed(), [id("guitar")]); + assert_eq!( + change.ramp, + Some(480), + "the master write takes no part in the ramp" + ); + assert!( + !none.same_solo(&change.overlay), + "the solo changed with the master" + ); + let master_only = none.after(&[master("bus", true)], &model); + assert_eq!(master_only.ramp, None); + assert!( + none.same_solo(&master_only.overlay) && none != master_only.overlay, + "a master alone changes the overlay and no solo" + ); + + let read = |overlay: &MonitorOverlay, strip: &str, control: u32| { + controls::get( + &model, + overlay, + &strip_own(strip, control, Lane::Both), + RATE, + ) + .expect("a listed row") + }; + assert_eq!(read(&master_only.overlay, "bus", MASTER), 1.0); + assert_eq!(read(&master_only.overlay, "vocal", MASTER), 0.0); + assert_eq!(read(&master_only.overlay, "vocal", SOLO), 0.0); + let listed = controls::list(&model, &master_only.overlay, RATE) + .filter(|row| row.address.control == MASTER && row.address.section == Section::Own) + .filter(|row| row.value == 1.0) + .map(|row| row.address.owner) + .collect::>(); + assert_eq!(listed, [Owner::Strip(id("bus"))]); + + // A removed master is no longer designated; a removed other strip leaves it. + let mut without_bus = model.clone(); + without_bus.submixes.clear(); + let mut overlay = master_only.overlay.clone(); + overlay.retain_strips_of(&without_bus); + assert_eq!(overlay.master(), None); + let mut without_guitar = model.clone(); + without_guitar + .tracks + .retain(|track| track.id != id("guitar")); + let mut overlay = master_only.overlay; + overlay.retain_strips_of(&without_guitar); + assert_eq!(overlay.master(), Some(&id("bus"))); +} diff --git a/crates/host-core/src/live_delta.rs b/crates/host-core/src/live_delta.rs index 574e6a717..501c60c54 100644 --- a/crates/host-core/src/live_delta.rs +++ b/crates/host-core/src/live_delta.rs @@ -401,18 +401,21 @@ pub fn classify_live_delta_with_monitor<'a>( let changes = controls::changed_controls(current, next, next.sample_rate_hz) .ok_or(LiveRebuild::Structure)?; let mut accumulated = Changes { - derived: current.vcas != next.vcas || monitor.committed != monitor.next, + derived: current.vcas != next.vcas || !monitor.committed.same_solo(monitor.next), ..Changes::default() }; for change in &changes { accumulated.note(next, change)?; } - // A strip whose solo differs between the two overlays changed its solo row, which is no - // model row and so has no entry in `changes` (#1600 D1, #1559). + // A strip whose solo or master designation differs between the two overlays changed its + // monitor row, which is no model row and so has no entry in `changes` (#1600 D1, #1559, + // #1683). The master moves no effective value, only the row. if monitor.committed != monitor.next { for (index, strip) in next.strips().enumerate() { - if monitor.committed.is_soloed(strip.id) != monitor.next.is_soloed(strip.id) { + if monitor.committed.is_soloed(strip.id) != monitor.next.is_soloed(strip.id) + || monitor.committed.is_master(strip.id) != monitor.next.is_master(strip.id) + { accumulated.note_owner(index); } } @@ -587,7 +590,7 @@ fn classify_rows<'a>( } let mut accumulated = Changes { - derived: monitor.committed != monitor.next, + derived: !monitor.committed.same_solo(monitor.next), ..Changes::default() }; for change in &changes { @@ -632,6 +635,18 @@ fn classify_rows<'a>( accumulated.note_owner(index); } } + // A master designation that moved changed the master row of the strip it left and of the + // strip it names (#1683); it moves no level, so it seeds nothing. + if monitor.committed.master() != monitor.next.master() { + for id in [monitor.committed.master(), monitor.next.master()] + .into_iter() + .flatten() + { + if let Some(index) = controls::strip_index(next, id) { + accumulated.note_owner(index); + } + } + } // Step 4 of the full classifier, for the strips whose rows changed. for item in &accumulated.work { diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index f423b9b92..f5e10b4b5 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -393,6 +393,11 @@ pub struct HostLiveControlRequest { /// a bus whose strip carries a limiter can now be designated. The field, its refusal code /// `host.observation.master_track` and the browser and SDK spellings keep their track-era /// names (DESIGN P17). + /// + /// It designates a first plan's master only (#1683): the designation is the strip's `master` + /// monitor row, which the boot seeds from this field and the app sets or changes after boot, + /// and a successor takes it from [`SuccessorBase::monitor`]. A successor's request that names + /// one refuses with `host.observation.master_track`. pub master_track: Option, } @@ -1927,6 +1932,39 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( let route_count = u64::try_from(model.routes.len()).map_err(|_| platform("host.count"))?; let effect_count = count_effects(model)?; caps.validate_shape(compiled)?; + // Issue #143 D6, widened to strips by issue #1208 D3 and made a monitor row by #1683: a + // designated master must name a strip this session actually has, or the frame would report a + // master reading nobody can address. A first plan takes it from the request (the browser's + // boot word), by strip index; a successor takes it from the overlay it renders under, by strip + // ID, so a strip added or removed before the master never moves the designation. A successor + // request that names one is refused: the overlay is the one designation. + let strip_total = model.tracks.len() + model.submixes.len(); + let master_track = match (successor, live_controls.master_track) { + (None, Some(index)) if (index as usize) < strip_total => Some(index), + (None, None) => None, + (None | Some(_), Some(_)) => return Err(shape("host.observation.master_track")), + (Some(base), None) => match base.monitor.next.master() { + Some(id) => Some( + controls::strip_index(model, id) + .and_then(|index| u32::try_from(index).ok()) + .ok_or_else(|| shape("host.observation.master_track"))?, + ), + None => None, + }, + }; + // The overlay the plan renders and lists under (#1559 D9): a successor's own; a first plan's + // solos nothing and holds the request's master (#1683), so the boot catalog lists it. + let boot_overlay = match (successor, master_track) { + (None, Some(index)) => { + let mut overlay = controls::MonitorOverlay::new(); + if let Some(strip) = model.strips().nth(index as usize) { + overlay.set_master(strip.id, true); + } + overlay + } + _ => controls::MonitorOverlay::new(), + }; + let overlay = successor.map_or(&boot_overlay, |base| base.monitor.next); let source_id_bytes = model.sources.iter().try_fold(0_usize, |total, source| { total @@ -2239,7 +2277,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( #[cfg(feature = "control-provider")] let control_catalog = crate::ControlCatalog::build( compiled.normalized_model(), - successor.map_or(&NO_OVERLAY, |base| base.monitor.next), + overlay, compiled.sample_rate().0, ) .map_err(|_| resource("host.control_provider.allocation"))?; @@ -2406,7 +2444,6 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( }; // Decision 17, C7: every effective value is computed once per preparation, here, under the // overlay the successor renders with (#1559 D9); a first plan has no solo. - let overlay = successor.map_or(&NO_OVERLAY, |base| base.monitor.next); let effective = controls::effective(compiled.normalized_model(), overlay); let builtins = if selected_meters.is_some() { // #1401 D2: a selected-meter request follows the caller's delivery contract, like every @@ -2717,14 +2754,6 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( meters.sort_by_key(|value| canonical_index[&*value.track_id]); } - // Issue #143 D6, widened to strips by issue #1208 D3: a designated master must name a strip - // this session actually has, or the frame would report a master reading nobody can address. - let master_track = match live_controls.master_track { - Some(index) if (index as usize) < live_control_strips.len() => Some(index), - Some(_) => return Err(shape("host.observation.master_track")), - None => None, - }; - // Issue #143 R7: walked over the built runtime, not derived from the request. A plan that // bound nothing reports zero because it *holds* nothing. let observation_retained_bytes = bound.plan.observation_retained_bytes(); diff --git a/crates/host-core/tests/effect_observation.rs b/crates/host-core/tests/effect_observation.rs index 713a80da3..221334065 100644 --- a/crates/host-core/tests/effect_observation.rs +++ b/crates/host-core/tests/effect_observation.rs @@ -536,11 +536,16 @@ fn prepare_successor( bindings: &controls::ObservationBindings, ) -> Session { let request = live_controls(Leg::AllArmed); + // #1683: a successor's master designation is its overlay's, never the request's. + let successor_request = HostLiveControlRequest { + master_track: None, + ..live_controls(Leg::AllArmed) + }; let (mut prepared, handles) = match &first { Some((first, committed)) => host_core::prepare_host_runtime_with_live_controls_successor( compiled, &caps(), - &request, + &successor_request, host_core::SuccessorBase { inventory: &first.prepared.inventory, committed, diff --git a/crates/host-core/tests/strip_meters.rs b/crates/host-core/tests/strip_meters.rs index 3c8f26759..fa73ece26 100644 --- a/crates/host-core/tests/strip_meters.rs +++ b/crates/host-core/tests/strip_meters.rs @@ -675,6 +675,81 @@ fn the_last_submix_can_be_designated_master_and_one_past_it_is_refused() { assert_eq!(codes, ["host.observation.master_track"], "{text}"); } +/// #1683: a successor's master is the strip its monitor overlay designates, by ID, resolved in the +/// successor's own strip order: a track added before the bus moves the bus's index and the +/// designation with it. The boot request's index designates the first plan only; a successor's +/// request that names one is refused. +/// +/// Red if a successor reuses the boot index (it would name `aux`, or refuse), ignores the overlay, +/// or quietly takes a successor request's index over the overlay's designation. +#[test] +fn a_successor_takes_its_master_from_the_overlay_by_strip_id() { + let document = two_bus_session(); + let request = |master: Option| HostLiveControlRequest { + meter_period_frames: NonZeroU32::new(QUANTUM as u32), + meter_queue_depth: NonZeroUsize::new(4).expect("meter depth"), + meter_tap: MeterTap::PostMatrix, + observation_taps: 1, + master_track: master, + }; + let (compiled, prepared, handles) = + prepare_host_session_with_live_controls(&document, &caps(), &request(Some(4))) + .unwrap_or_else(|failure| { + panic!("prepare: {}", String::from_utf8_lossy(failure.as_bytes())) + }); + assert_eq!(&*handles.strips[4], "bus"); + assert_eq!(handles.master_track, Some(4)); + + // One more track, whose ID sorts first: every submix moves one index on. + let mut grown = compiled.normalized_model().clone(); + let mut added = grown.tracks[0].clone(); + added.id = sid("a-first"); + grown.tracks.insert(0, added); + let grown_document = canonical_session_json(&grown).expect("grown session canonicalizes"); + let grown_compiled = compile_host_session(&grown_document, &caps()).expect("grown compiles"); + let mut overlay = controls::MonitorOverlay::new(); + overlay.set_master(&sid("bus"), true); + let successor = |live: &HostLiveControlRequest, overlay: &controls::MonitorOverlay| { + host_core::prepare_host_runtime_with_live_controls_successor( + &grown_compiled, + &caps(), + live, + host_core::SuccessorBase { + inventory: &prepared.inventory, + committed: compiled.normalized_model(), + revision: 2, + edit_ramps: &session::EditRamps::new(), + monitor: host_core::MonitorBase { + committed: overlay, + next: overlay, + ramp: None, + }, + observation: &host_core::NO_OBSERVATION, + }, + ) + }; + let (_, grown_handles) = successor(&request(None), &overlay).unwrap_or_else(|failure| { + panic!("successor: {}", String::from_utf8_lossy(failure.as_bytes())) + }); + assert_eq!(&*grown_handles.strips[5], "bus"); + assert_eq!(grown_handles.master_track, Some(5), "the bus, by ID"); + + let (_, undesignated) = successor(&request(None), &controls::MonitorOverlay::new()) + .unwrap_or_else(|failure| { + panic!("successor: {}", String::from_utf8_lossy(failure.as_bytes())) + }); + assert_eq!(undesignated.master_track, None); + + let Err(failure) = successor(&request(Some(4)), &overlay) else { + panic!("a successor request that names a master must be refused"); + }; + let text = String::from_utf8_lossy(failure.as_bytes()).into_owned(); + assert!( + text.starts_with("host.observation.master_track\t"), + "{text}" + ); +} + /// Gate 4: with a meter at each of `bus`'s seven boundaries, every render after block 0 allocates /// and frees nothing: the render audit and `bench_support::alloc`'s thread-scoped counters both /// read exact zero around each call. diff --git a/crates/protocol/src/controller/tests.rs b/crates/protocol/src/controller/tests.rs index 0371e3d18..710f605dd 100644 --- a/crates/protocol/src/controller/tests.rs +++ b/crates/protocol/src/controller/tests.rs @@ -1653,7 +1653,8 @@ fn a_transaction_of_monitor_writes_commits_no_model_revision_or_event() { prepared.monitor_edits(), [crate::control_core::MonitorWrite { strip: track.clone(), - soloed: true, + row: crate::control_core::MonitorRow::Solo, + on: true, ramp: Some(96), }] ); @@ -1741,7 +1742,8 @@ fn a_transaction_of_monitor_writes_commits_no_model_revision_or_event() { prepared.monitor_edits(), [crate::control_core::MonitorWrite { strip: track.clone(), - soloed: false, + row: crate::control_core::MonitorRow::Solo, + on: false, ramp: Some(20), }] ); diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 13cf885af..963631b6d 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -42,7 +42,7 @@ pub mod control_core { pub use crate::control_rows::{builtins_edits, fader_edits, matrix_or_pan_edits, row_address}; pub use controls::{ BYPASS_CONTROL, ControlAddress, ControlError, LINK_MODE_CONTROL, Lane, MonitorOverlay, - MonitorWrite, Owner, Section, + MonitorRow, MonitorWrite, Owner, Section, }; } pub use model::{ diff --git a/crates/protocol/src/model.rs b/crates/protocol/src/model.rs index 5d684b522..9ab9d264f 100644 --- a/crates/protocol/src/model.rs +++ b/crates/protocol/src/model.rs @@ -1328,8 +1328,9 @@ impl SessionStore { controls::check_monitor(&candidate, address, *value, *ramp_samples, rate) .map_err(|error| refused(SessionEditError::Control(error)))?; if let Some(write) = write { - // A later write of one strip replaces the earlier one. - monitor_edits.retain(|earlier| earlier.strip != write.strip); + // A later write of one row of one strip replaces the earlier one. + monitor_edits + .retain(|earlier| earlier.strip != write.strip || earlier.row != write.row); monitor_edits.push(write); continue; } @@ -1450,7 +1451,8 @@ impl SessionStore { if let Some(write) = controls::check_monitor(committed, address, *value, *ramp_samples, rate).ok()? { - monitor_edits.retain(|earlier| earlier.strip != write.strip); + monitor_edits + .retain(|earlier| earlier.strip != write.strip || earlier.row != write.row); monitor_edits.push(write); continue; } diff --git a/hosts/host-web/src/lib.rs b/hosts/host-web/src/lib.rs index 160b29489..21a50a314 100644 --- a/hosts/host-web/src/lib.rs +++ b/hosts/host-web/src/lib.rs @@ -1642,10 +1642,36 @@ impl ReadyOwnership { } if result.is_ok() { self.stamp_structure_epoch(); + self.sync_master(); } result } + /// Point every plan's tables the host holds at the master strip the core designates now + /// (#1683): the rendering plan's and each replacement's that waits for its adoption, each by + /// the strip's ID in that plan's own strip order, so a strip added or removed before the + /// master never moves the reading to another strip. A plan that does not hold the strip has no + /// master. The designation moves no render value; the next poll folds the master reading from + /// it. Control side only, as [`Self::stamp_structure_epoch`]. + fn sync_master(&mut self) { + let master = self.control.master_strip(); + let shapes = core::iter::once(&mut *self.shape).chain( + self.pending_shapes + .iter_mut() + .filter(|entry| !entry.adopted) + .map(|entry| &mut *entry.shape), + ); + for shape in shapes { + let index = master.and_then(|id| { + strip_index(&shape.tracks, &shape.submixes, id.as_str().as_bytes()) + .and_then(|index| u32::try_from(index).ok()) + }); + shape.master_track = index; + shape.meter_header.master_track_plus_one = + index.map_or(0, |track| track.saturating_add(1)); + } + } + /// Stamp the structure epoch the control list holds now on the newest committed plan's meter /// header (#1562b): the replacement's while one waits, else the rendering plan's. A commit that /// rode the newest plan's live lanes may have changed a descriptor or an address (a VCA added, @@ -5542,10 +5568,15 @@ impl WebRuntimePreparer { superseded: &host_core::LiveValuesSuperseded, boot: bool, ) -> Result<(PreparedHost, host_core::HostLiveControlHandles), PrepareDiagnostics> { - let live_controls = - live_control_request(self.options, compiled.quantum().0).ok_or_else(|| { + let mut live_controls = live_control_request(self.options, compiled.quantum().0) + .ok_or_else(|| { PrepareDiagnostics::fixed(PrepareRejection::Session, "web.live_controls.config") })?; + // #1683: the boot word designates the first plan's master only; the core's overlay, which + // it seeds, designates every successor's. + if successor.is_some() { + live_controls.master_track = None; + } // Observation taps ride the effect lanes (#1254 D2): an app that asked for taps gets the // effect lanes whatever the session's live rows chose, as before the control plane chose // the lanes, so a session with no effect to observe still boots. diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index b73b80ba7..8c0a44ea6 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -8035,6 +8035,187 @@ mod structural_apply { "the successor has no insert, so no slot to read" ); } + + /// The `master` monitor row of strip `strip` (#1683), as `SET_CONTROL` addresses it. + fn master_edit(strip: &str, on: bool) -> SessionEdit { + SessionEdit::SetControl { + address: controls::ControlAddress { + owner: controls::Owner::Strip(StableId::parse(strip).expect("a strip ID")), + section: controls::Section::Own, + control: 4, + lane: controls::Lane::Both, + }, + value: if on { 1.0 } else { 0.0 }, + ramp_samples: None, + expected_effect: None, + } + } + + /// The gain-reduction word of strip `strip` and the master's word, when the header says it + /// is present. + fn master_reading(host: &AudioWorkletEngineHost, strip: usize) -> (f32, Option) { + let strips = host.live_control_tracks().len() + host.meter_header().submix_count as usize; + let frame = host.meter_frame(); + let base = strips * 2 + 2; + let master = (host.meter_header().master_gr_present == 1).then(|| frame[base + strips]); + (frame[base + strip], master) + } + + /// #1683 gate 1: a session that boots empty (the boot word cannot name a strip it does not + /// have) gets its master through the core after boot. A track with a compressor arrives by a + /// rebuild; one `SET_CONTROL` of its `master` row designates it, a monitor write that spends no + /// revision; the meter header names it and the master reading is that strip's own word, as a + /// boot-time designation reads (`the_meter_frame_carries_the_app_shaped_gain_reduction`). A + /// track added before it by a later rebuild moves its index and the designation follows it by + /// ID; a strip the session does not have is refused before commit; clearing the row makes the + /// reading absent, never zero. + /// + /// Test value: red if the designation cannot be set after boot, is held by index (the reading + /// would move to the added track), is not carried into a successor plan, reaches the meter + /// header late or never, spends a revision, or a designation of an unknown strip is admitted. + #[test] + fn a_master_designated_after_an_empty_boot_reads_as_a_boot_designation() { + const WINDOW: u64 = 2; + let full = parse_session_json(include_str!( + "../../../fixtures/session/v1/observation-frame-shape.json" + )) + .expect("the observation fixture parses"); + let mut empty = full.clone(); + empty.sources.clear(); + empty.tracks.clear(); + empty.routes.clear(); + let options = WebBootOptions { + source_ring_frames: QUANTUM * 4, + live_control_meter_blocks: WINDOW, + live_control_observation_taps: 4, + ..boot_options(QUANTUM) + }; + let document = canonical_session_json(&empty).expect("canonical empty session"); + let failure = AudioWorkletEngineHost::boot( + document.as_bytes(), + WebBootOptions { + live_control_master_track_plus_one: 1, + ..options + }, + ) + .err() + .expect("the boot word cannot name a strip an empty session lacks"); + assert!( + failure + .diagnostic() + .starts_with(b"host.observation.master_track\t") + ); + + let mut rig = Rig::boot(&empty, options, None); + assert_eq!(rig.host.meter_header().master_track_plus_one, 0); + let track = |index: usize, id: &str| { + let mut track = full.tracks[index].clone(); + let mut route = full.routes[index].clone(); + track.id = StableId::parse(id).expect("a track ID"); + route.id = StableId::parse(&format!("{id}-main")).expect("a route ID"); + let RouteSource::Track { track_id, .. } = &mut route.source else { + panic!("the fixture routes a track") + }; + *track_id = track.id.clone(); + [ + SessionEdit::UpsertTrack { track }, + SessionEdit::UpsertRoute { route }, + ] + }; + let mut first = vec![SessionEdit::UpsertSource { + source: full.sources[0].clone(), + }]; + first.extend(track(0, "t0")); + assert!(matches!( + rig.apply(&first, 1), + Outcome::Applied { + path: EditPath::Rebuild, + .. + } + )); + assert_eq!(rig.host.render_next(), RESULT_OK, "the rebuild is adopted"); + + // An unknown strip is refused before commit, and nothing is designated. + let revision = rig.host.committed_revision(); + assert!(matches!( + rig.apply(&[master_edit("nobody", true)], 2), + Outcome::Refused(..) + )); + assert_eq!(rig.host.meter_header().master_track_plus_one, 0); + + assert_eq!( + rig.apply(&[master_edit("t0", true)], 3), + Outcome::Applied { + path: EditPath::Monitor, + revision, + }, + "a monitor write spends no revision" + ); + assert_eq!(rig.host.meter_header().master_track_plus_one, 1); + + assert_eq!(rig.host.set_meter_lease(true), RESULT_OK); + assert_eq!( + observe_key( + &mut rig.host, + "t0", + RACK_INSERTS, + "comp", + 1, + WINDOW as u32, + true + ), + RESULT_OK + ); + assert_eq!(rig.host.seek_source(SOURCE, 2, 0), RESULT_OK); + let mut fed = 0; + let mut feed = |host: &mut AudioWorkletEngineHost, blocks: u64| { + for _ in 0..blocks { + feed_and_render(host, 2, fed, 0.5); + fed += 1; + } + }; + feed(&mut rig.host, 8 * WINDOW); + assert!(rig.host.poll_meters() > 0, "a window closed"); + let (word, master) = master_reading(&rig.host, 0); + assert!(word > 0.0, "the compressor reduces: {word}"); + assert_eq!( + master.map(f32::to_bits), + Some(word.to_bits()), + "the master is t0's own word" + ); + + // A track whose ID sorts first arrives by a rebuild: t0 is strip 1 now, and stays the + // master. + assert!(matches!( + rig.apply(&track(1, "a0"), 4), + Outcome::Applied { + path: EditPath::Rebuild, + .. + } + )); + feed(&mut rig.host, 8 * WINDOW); + assert_eq!(rig.host.meter_header().master_track_plus_one, 2); + assert!(rig.host.poll_meters() > 0, "a window closed"); + let (word, master) = master_reading(&rig.host, 1); + assert!(word > 0.0, "t0 still reduces: {word}"); + assert_eq!(master.map(f32::to_bits), Some(word.to_bits())); + + // Cleared: absent, not zero. + let revision = rig.host.committed_revision(); + assert_eq!( + rig.apply(&[master_edit("t0", false)], 5), + Outcome::Applied { + path: EditPath::Monitor, + revision, + } + ); + assert_eq!(rig.host.meter_header().master_track_plus_one, 0); + feed(&mut rig.host, 4 * WINDOW); + assert!(rig.host.poll_meters() > 0, "a window closed"); + let (word, master) = master_reading(&rig.host, 1); + assert!(word > 0.0); + assert_eq!(master, None, "no designation reads absent"); + } } /// A subscription past the engine's cap on armed taps (#1676) is refused as a whole before any diff --git a/hosts/host-web/tests/browser-v1/expected.json b/hosts/host-web/tests/browser-v1/expected.json index 7c73d912b..b86b962f4 100644 --- a/hosts/host-web/tests/browser-v1/expected.json +++ b/hosts/host-web/tests/browser-v1/expected.json @@ -36,7 +36,7 @@ "quantumFrames": 128, "nextAbsoluteSample": "0", "renderedQuanta": "0", - "memoryBytes": 2818048 + "memoryBytes": 2883584 }, "beforeDisposeStatus": { "state": 2, @@ -46,7 +46,7 @@ "quantumFrames": 128, "nextAbsoluteSample": "384", "renderedQuanta": "3", - "memoryBytes": 2818048 + "memoryBytes": 2883584 }, "resources": { "sampleRateHz": 48000, diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 9229f0033..9b6e631e6 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -2751,3 +2751,102 @@ fn both_hosts_boot_an_empty_session_and_add_the_first_stem_alike() { assert!(heard, "{rate} Hz: the first stem is heard"); } } + +/// #1683: the master designation is the core's `master` monitor row on both hosts. Both boot an +/// empty session, grow a track and a submix by one rebuild, then designate, move and clear the +/// master with `SET_CONTROL`, and refuse a strip neither has: each transaction's verdict, the +/// control list and state pages (the row's value) and the snapshot (which never holds it) agree. +/// +/// Test value: red if one host lacks the row, refuses a designation the other takes, spends a +/// revision on it, lists another master, holds two masters at once, saves the designation into +/// the snapshot, or admits a strip the session does not have. +#[test] +fn both_hosts_designate_the_master_strip_after_an_empty_boot_alike() { + let rate = RATES[0]; + let mut model = model_at(rate); + let (mut source, mut track, mut route) = ( + model.sources[0].clone(), + model.tracks[0].clone(), + model.routes[0].clone(), + ); + model.sources.clear(); + model.tracks.clear(); + model.routes.clear(); + model.vcas.clear(); + source.id = id("stem"); + track.id = id("t1"); + track.source_id = source.id.clone(); + route.id = id("t1-main"); + let session::RouteSource::Track { track_id, .. } = &mut route.source else { + panic!("the fixture routes a track") + }; + *track_id = track.id.clone(); + let bus = session::Submix::unity(id("bus"), &model.console); + let master = |strip: &str, on: bool| SessionEdit::SetControl { + address: address(Owner::Strip(id(strip)), Section::Own, 4, Lane::Both), + value: if on { 1.0 } else { 0.0 }, + ramp_samples: None, + expected_effect: None, + }; + let transactions = [ + vec![ + SessionEdit::UpsertSource { source }, + SessionEdit::UpsertTrack { track }, + SessionEdit::UpsertRoute { route }, + SessionEdit::UpsertSubmix { submix: bus }, + ], + vec![master("t1", true)], + vec![master("bus", true)], + vec![master("nobody", true)], + vec![master("t1", false)], + vec![master("bus", false)], + ]; + + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + for (step, edits) in transactions.iter().enumerate() { + let before = c_abi.revision; + let (b, _) = browser.transaction_reply(edits); + let (c, _) = c_abi.transaction(edits); + assert_eq!(b, c, "step {step}: the same verdict"); + match step { + 0 => assert!( + matches!( + b, + Verdict::Applied { + path: EditPath::Rebuild, + .. + } + ), + "{b:?}" + ), + 3 => assert!(matches!(b, Verdict::Refused { .. }), "{b:?}"), + _ => assert_eq!( + b, + Verdict::Applied { + path: EditPath::Monitor, + revision: before, + }, + "step {step}: a monitor write spends no revision" + ), + } + assert_eq!(browser.host.render_next(), host_web::RESULT_OK); + let _ = c_abi.render(step as u64); + let (b_rows, b_snapshot) = dump(|payload, expected| browser.command(payload, expected)); + let (c_rows, c_snapshot) = dump(|payload, expected| c_abi.send(payload, expected)); + assert_eq!(b_rows, c_rows, "step {step}: the control pages agree"); + assert_eq!(b_snapshot, c_snapshot, "step {step}: the snapshots agree"); + // The listed masters: the owner of each `master` row whose value is on. + let designated: Vec<&str> = b_rows + .iter() + .filter(|row| row.contains("name: \"master\"") && row.contains("value: 1.0 }")) + .filter_map(|row| row.split("owner_id: Some(\"").nth(1)?.split('"').next()) + .collect(); + let expected: &[&str] = match step { + 1 => &["t1"], + 2..=4 => &["bus"], + _ => &[], + }; + assert_eq!(designated, expected, "step {step}: the listed master"); + } +} diff --git a/hosts/host-web/tests/fixtures/control-list.json b/hosts/host-web/tests/fixtures/control-list.json index c838a61b2..413b99b26 100644 --- a/hosts/host-web/tests/fixtures/control-list.json +++ b/hosts/host-web/tests/fixtures/control-list.json @@ -5,6 +5,7 @@ { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "matrix_mode", "class": "live", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 2 }, { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "solo", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "pan_window_samples", "class": "live", "kind": "continuous", "unit": "samples", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "4f800000", "defaultBits": "00000000", "valueBits": "43480000", "choices": 0 }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "master", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0000000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0000000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 65, "lane": "left", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, @@ -131,6 +132,7 @@ { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "matrix_mode", "class": "live", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 2 }, { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "solo", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "pan_window_samples", "class": "live", "kind": "continuous", "unit": "samples", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "4f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "master", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0400000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0400000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 65, "lane": "left", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, @@ -211,6 +213,7 @@ { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "matrix_mode", "class": "live", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 2 }, { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "solo", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "pan_window_samples", "class": "live", "kind": "continuous", "unit": "samples", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "4f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "master", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0800000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0800000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 65, "lane": "left", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, @@ -301,6 +304,7 @@ { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "matrix_mode", "class": "live", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 2 }, { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "pan_window_samples", "class": "live", "kind": "continuous", "unit": "samples", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "4f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "master", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 65, "lane": "left", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, 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 f2fe46311..d18704945 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 @@ -559,7 +559,8 @@ export interface MisoMeterFrame { /// or after their last subscribe, and `0` when no armed tap has such a window. `masterGrDb` is /// `null` when the designated strip has none. readonly trackGrDb: Float32Array; - /// The designated master strip's own folded reading, or `null` (issue 143 D6). + /// The designated master strip's own folded reading, or `null` (issue 143 D6). The master is the + /// strip whose `master` monitor row is on (issue 1683), or the boot word's strip until one is set. /// /// `null` -- never `0` -- when no strip was designated or the designated strip published no /// window, because `0` would be indistinguishable from "the master is not reducing". @@ -711,7 +712,9 @@ export interface MisoWebBootOptions { /// /// V1 has no structural master bus, so `masterGrDb` is a designation rather than a discovery. /// Plus one because zero has to keep meaning "unset". Requires - /// `liveControlObservationTaps !== 0n`. + /// `liveControlObservationTaps !== 0n`. This word designates the first plan only (issue + /// 1683): after boot the app sets or moves the master with the strip's `master` monitor row + /// (`SET_CONTROL`), the core holds it by strip ID, and every later plan follows it. liveControlMasterTrackPlusOne: bigint; /// One optional prepared graph boundary; `null` retains no capture storage. spectrum?: MisoSpectrumBootOptions | null; diff --git a/sdk/assets/miso-engine-v1-parameter-metadata.json b/sdk/assets/miso-engine-v1-parameter-metadata.json index 6dd156158..5fc5ef362 100644 --- a/sdk/assets/miso-engine-v1-parameter-metadata.json +++ b/sdk/assets/miso-engine-v1-parameter-metadata.json @@ -1276,6 +1276,7 @@ { "control": 1, "name": "matrix_mode", "displayName": "Pan or Matrix", "displayUnit": "", "unit": "linear", "kind": "enumeration", "minimum": 0.0, "maximum": 1.0, "disabledValue": null, "default": 0.0, "taper": "stepped", "step": { "unit": "index", "size": "1", "precision": 0, "ladder": { "xs": 1, "sm": 3, "md": 5, "lg": 10, "xl": 30 } }, "choices": [{ "value": 0.0, "label": "pan" }, { "value": 1.0, "label": "matrix" }], "ramp": "pan", "scope": "model", "family": "stripMatrix", "lanes": "shared", "class": "live", "reason": null, "reasonIssue": null, "reopen": null, "automatable": false }, { "control": 2, "name": "solo", "displayName": "Solo", "displayUnit": "", "unit": "linear", "kind": "toggle", "minimum": 0.0, "maximum": 1.0, "disabledValue": null, "default": 0.0, "taper": "stepped", "step": { "unit": "index", "size": "1", "precision": 0, "ladder": { "xs": 1, "sm": 3, "md": 5, "lg": 10, "xl": 30 } }, "choices": [], "ramp": "solo", "scope": "monitor", "family": "stripLevel", "lanes": "shared", "class": "live", "reason": null, "reasonIssue": null, "reopen": null, "automatable": false, "onSubmix": false }, { "control": 3, "name": "pan_window_samples", "displayName": "Pan Window", "displayUnit": "samples", "unit": "samples", "kind": "continuous", "minimum": 0.0, "maximum": 4294967300.0, "disabledValue": null, "default": 0.0, "taper": "linear", "step": { "unit": "absolute", "size": "1", "precision": 0, "ladder": { "xs": 1, "sm": 3, "md": 5, "lg": 10, "xl": 30 } }, "choices": [], "ramp": "fixed", "scope": "model", "family": "setting", "lanes": "shared", "class": "live", "reason": null, "reasonIssue": null, "reopen": null, "automatable": false }, + { "control": 4, "name": "master", "displayName": "Master", "displayUnit": "", "unit": "linear", "kind": "toggle", "minimum": 0.0, "maximum": 1.0, "disabledValue": null, "default": 0.0, "taper": "stepped", "step": { "unit": "index", "size": "1", "precision": 0, "ladder": { "xs": 1, "sm": 3, "md": 5, "lg": 10, "xl": 30 } }, "choices": [], "ramp": "fixed", "scope": "monitor", "family": "setting", "lanes": "shared", "class": "live", "reason": null, "reasonIssue": null, "reopen": null, "automatable": false }, { "control": 64, "name": "effective_fader_db", "displayName": "Effective Fader", "displayUnit": "dB", "unit": "db", "kind": "continuous", "minimum": -144.0, "maximum": 24.0, "disabledValue": null, "default": 0.0, "taper": "decibel", "step": { "unit": "absolute", "size": "0.1", "precision": 1, "ladder": { "xs": 1, "sm": 3, "md": 5, "lg": 10, "xl": 30 } }, "choices": [], "ramp": "fixed", "scope": "model", "family": "stripLevel", "lanes": "perLane", "class": "derived", "reason": null, "reasonIssue": null, "reopen": null, "automatable": false }, { "control": 65, "name": "effective_mute", "displayName": "Effective Mute", "displayUnit": "", "unit": "linear", "kind": "toggle", "minimum": 0.0, "maximum": 1.0, "disabledValue": null, "default": 0.0, "taper": "stepped", "step": { "unit": "index", "size": "1", "precision": 0, "ladder": { "xs": 1, "sm": 3, "md": 5, "lg": 10, "xl": 30 } }, "choices": [], "ramp": "fixed", "scope": "model", "family": "stripLevel", "lanes": "perLane", "class": "derived", "reason": null, "reasonIssue": null, "reopen": null, "automatable": false } ], diff --git a/sdk/src/browser/shipped-host.d.ts b/sdk/src/browser/shipped-host.d.ts index f2fe46311..d18704945 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -559,7 +559,8 @@ export interface MisoMeterFrame { /// or after their last subscribe, and `0` when no armed tap has such a window. `masterGrDb` is /// `null` when the designated strip has none. readonly trackGrDb: Float32Array; - /// The designated master strip's own folded reading, or `null` (issue 143 D6). + /// The designated master strip's own folded reading, or `null` (issue 143 D6). The master is the + /// strip whose `master` monitor row is on (issue 1683), or the boot word's strip until one is set. /// /// `null` -- never `0` -- when no strip was designated or the designated strip published no /// window, because `0` would be indistinguishable from "the master is not reducing". @@ -711,7 +712,9 @@ export interface MisoWebBootOptions { /// /// V1 has no structural master bus, so `masterGrDb` is a designation rather than a discovery. /// Plus one because zero has to keep meaning "unset". Requires - /// `liveControlObservationTaps !== 0n`. + /// `liveControlObservationTaps !== 0n`. This word designates the first plan only (issue + /// 1683): after boot the app sets or moves the master with the strip's `master` monitor row + /// (`SET_CONTROL`), the core holds it by strip ID, and every later plan follows it. liveControlMasterTrackPlusOne: bigint; /// One optional prepared graph boundary; `null` retains no capture storage. spectrum?: MisoSpectrumBootOptions | null; diff --git a/sdk/src/generated/control-builders.ts b/sdk/src/generated/control-builders.ts index 3f95da88a..91598d196 100644 --- a/sdk/src/generated/control-builders.ts +++ b/sdk/src/generated/control-builders.ts @@ -75,6 +75,14 @@ export abstract class StripEdits extends StripAddressing { ); } + /** `master`: Master. */ + master(enabled: boolean): ControlEditSet { + return this.setControl( + { owner: this.controlOwner(), section: { kind: "own" }, control: "master", lane: "both" }, + flag(enabled), + ); + } + /** `pan` of each lane: `left` on the left lane and `right` on the right. */ pan(left: number, right: number, options: SmoothingOptions = {}): ControlEditSet { const ramp = rampOptions(options); @@ -1028,6 +1036,17 @@ export const CONTROL_BUILDERS: readonly ControlBuilderEntry[] = "pan_window_samples" ] }, + { + "owner": "strip", + "section": "own", + "method": "master", + "shape": "row", + "valueKind": "boolean", + "options": "none", + "rows": [ + "master" + ] + }, { "owner": "strip", "section": "builtins", diff --git a/sdk/src/generated/controls.ts b/sdk/src/generated/controls.ts index 03bc1aafb..809644a3f 100644 --- a/sdk/src/generated/controls.ts +++ b/sdk/src/generated/controls.ts @@ -185,6 +185,41 @@ export const CONTROLS = deepFreeze( "reopen": null, "automatable": false }, + { + "control": 4, + "name": "master", + "displayName": "Master", + "displayUnit": "", + "unit": "linear", + "kind": "toggle", + "minimum": 0, + "maximum": 1, + "disabledValue": null, + "default": 0, + "taper": "stepped", + "step": { + "unit": "index", + "size": "1", + "precision": 0, + "ladder": { + "xs": 1, + "sm": 3, + "md": 5, + "lg": 10, + "xl": 30 + } + }, + "choices": [], + "ramp": "fixed", + "scope": "monitor", + "family": "setting", + "lanes": "shared", + "class": "live", + "reason": null, + "reasonIssue": null, + "reopen": null, + "automatable": false + }, { "control": 64, "name": "effective_fader_db", From c19b867cdd9714c61b8ec20495697355b80164ee Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 19:36:13 +0000 Subject: [PATCH 07/10] #1645: the fuzz lockfile gains the automation crate (controls now depends on it) Co-Authored-By: Claude Opus 5.5 --- fuzz/Cargo.lock | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 430e55103..3b54bca50 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -34,6 +34,13 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "automation" +version = "0.1.0" +dependencies = [ + "math", +] + [[package]] name = "builtins" version = "0.1.0" @@ -82,6 +89,7 @@ dependencies = [ name = "controls" version = "0.1.0" dependencies = [ + "automation", "builtins", "effect-compiler", "effect-contract", From 64bd6b951e4c4791d0b5004b8210933d147fd9cf Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 19:53:23 +0000 Subject: [PATCH 08/10] Batch 14a review fold: records and docs (#1645 reach ruling, #1660, #1662, #1683, #1685, #1686; placeholder TBD37) Co-Authored-By: Claude Opus 5.5 --- ...iltin-automation-targets-at-preparation.md | 14 ++++ ...0-render-stored-input-filter-automation.md | 11 ++++ ...ect-node-in-pieces-at-automation-events.md | 5 ++ ...ate-the-browser-master-strip-after-boot.md | 14 ++++ ...-exact-encoded-bytes-of-the-ended-event.md | 12 ++++ ...-and-end-from-prepared-automation-spans.md | 7 ++ ...in-the-browser-wasm-memory-as-a-ceiling.md | 66 +++++++++++++++++++ docs/EFFECT_CONTRACT_V1.md | 3 +- docs/SESSION_SCHEMA_V1.md | 5 +- 9 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 .github/ISSUE_SPECS/TBD37-pin-the-browser-wasm-memory-as-a-ceiling.md diff --git a/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md b/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md index 163c96ce4..80da9fbfd 100644 --- a/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md +++ b/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md @@ -331,3 +331,17 @@ Checked against `12d4a4f9d`. again with the step-2 change but without the fast-path fallback, both times `RESULT_OK` (0) against `RESULT_COMPILE_REJECTED` (5); a move to 2 kHz commits live). #1660 keeps only the classification of input-filter edits on an automated strip, not this refusal. + +**Review fold (batch 14a).** + +- **Root ruling (2026-10-15): the order rule has a reach.** The input-filter order rule is checked + on each sub-interval widened by the cell's reach, 64 samples. That is #1637's grid lookahead + (`crates/automation/src/events.rs`, `target_at`) and the filter's jump ramp. So stored automation + is never accepted and then refused in render. The reviewer's counterexample is now a refused + case: the HPF holds 900 Hz on [0,1000) and then steps to 100; the LPF is linear from 1000 to 300 + on [1000,1010). Before the widening, each piece was ordered, but the LPF target at sample 950 is + its value at 1014, 300 Hz, below the HPF's 900 Hz. `docs/SESSION_SCHEMA_V1.md` states the rule + with the reach. +- **#1660 must keep its reach at or below 64 samples**, or widen this rule in the same change. +- **Path.** The fold edits `crates/host-core/src/live_delta.rs`. That file is outside the original + authorized paths. The coordinator approved the edit. diff --git a/.github/ISSUE_SPECS/1660-render-stored-input-filter-automation.md b/.github/ISSUE_SPECS/1660-render-stored-input-filter-automation.md index afa434079..9131ec36b 100644 --- a/.github/ISSUE_SPECS/1660-render-stored-input-filter-automation.md +++ b/.github/ISSUE_SPECS/1660-render-stored-input-filter-automation.md @@ -219,3 +219,14 @@ Checked against `12d4a4f9d`. yet" and the dependency on root's confirmation are met. D4's per-block bound is the bound F1's condition 2 names; its gate stays. The contract text is #1659's (D5 of its batch-10 amendment), for owner review. + +## Amendment (controls coordinator, batch 14a) + +- **#1645's fold now refuses a static HPF or LPF edit that crosses an automated curve.** It does so + in the classifier's step 2 and in the fast path, with `LiveRebuild::AutomationTarget` and code + `control.automation.order`. The names in this spec, `LiveRebuild::Domain`, `builtin.filter.order` + and `input_filter_order_diagnostics`, are superseded by those. +- **#1660 keeps only the classification of input-filter edits on an automated strip** (the + automation edits that are carried rebuilds, and the record rules of #1648). +- **The reach.** Root ruled on 2026-10-15 that #1645's order rule is checked widened by 64 samples. + #1660's render reach must stay at or below 64 samples, or the rule must widen in the same change. diff --git a/.github/ISSUE_SPECS/1662-process-an-effect-node-in-pieces-at-automation-events.md b/.github/ISSUE_SPECS/1662-process-an-effect-node-in-pieces-at-automation-events.md index e6ca3ef66..8fb1e7c47 100644 --- a/.github/ISSUE_SPECS/1662-process-an-effect-node-in-pieces-at-automation-events.md +++ b/.github/ISSUE_SPECS/1662-process-an-effect-node-in-pieces-at-automation-events.md @@ -219,3 +219,8 @@ Checked against `12d4a4f9d`. takes no live record (A10, #1665), so a piece stages each cell at most once. No #1306 term. - **D3 also keeps** the bypass crossfade step of #1341a per block. - **Dependencies.** #1345 has landed; #1611 and #1621 replace #1306 here. + +## Amendment (batch 14a) + +- **D1's `Point`.** D1 says `start_sample = end_sample`. After #1686, `PreparedAutomationSpan` is + `{channel, parameter_index, sample, value}`: a `Point` has one `sample`, the event's `r0 + offset`. diff --git a/.github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md b/.github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md index b047f6d09..d41a2da88 100644 --- a/.github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md +++ b/.github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md @@ -116,3 +116,17 @@ before the master, it named another strip, or refused the rebuild). - SDK regenerated: `sdk/assets/miso-engine-v1-parameter-metadata.json`, `sdk/src/generated/controls.ts`, `sdk/src/generated/control-builders.ts`. No wire change, so no schema hash moved. + +**Departures (batch 14a, appended).** "Departures: none" above needs one line. The C ABI lists, +accepts and reads back the `master` row, but nothing on the C ABI consumes it yet. Under decision 17 +C1 this is correct: the designation is core state, readable on both hosts. Its reading on the C ABI +is the successor TBD36 ("Read the master strip's meter on the C ABI"). + +**Review fold (batch 14a).** + +- **The master takes effect at adoption.** `sync_master` updates only the pending shapes once a + replacement is published, so the rendering plan keeps its master until the swap. +- **A solo and a master write on one strip in one transaction both apply.** The replace rule of + `protocol` is per strip and row. +- **The boot word with no observation taps is refused; the row is accepted with a null reading.** + The two are documented side by side. 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 index b5198507c..71e03ce0f 100644 --- 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 @@ -166,3 +166,15 @@ admission through `SessionState`. `scripts/check-browser-expected-resources.py`, `scripts/check-sdk-headless.sh` (559 evals), `scripts/check-sdk-generated.sh`, `check-command-reason-vocabulary.py`, `realtime-policy`, clippy `-D warnings`, `fmt --check`: pass. + +**Correction (batch 14a).** The departure above says a 64-tap C ABI frame is "about 5.2 KiB". The +measured figure is 5,184 bytes (about 5.06 KiB) for strip and effect IDs of 8 bytes or less, and +6,208 bytes with 16-byte IDs. The comment on `maximum_control_frame_bytes` in +`crates/capi/include/miso_engine_v1.h` gives the rule: 64 bytes plus, per armed tap, 64 bytes plus +each ID padded to a multiple of 8. + +**Review fold (batch 14a).** + +- **The binding-table reserve charges two peaks.** It charges the capacity peak and the text peak + separately. The `Vec` keeps its high-water capacity, while the text belongs to a later armed set, + so the two peaks do not come from one armed set. 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 index 146891cb7..7248febe0 100644 --- 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 @@ -127,3 +127,10 @@ No retained byte row or fixture byte count moved (the estimate reads `size_of::< console-workload, parameter-metadata, audit: all pass. `check-effect-runtime-policy.sh`, `check-workspace-policy.sh`, `check-effect-runtime-fixtures.sh`, `realtime-policy`: ok. `cargo clippy --workspace --all-targets --all-features -- -D warnings` and `cargo fmt --all --check`: clean. + +**Review fold (batch 14a).** The departure above lists "modulus 5 to 4" for the span validation +fuzz. That change stands for the span loop, which has four cases. The descriptor loop in the same +test (`ten_thousand_descriptor_and_span_mutations_reject_without_panic`) also took the modulus 4 +by mistake, and its fifth case, the duplicate parameter ID, became unreachable. Its modulus is +restored to 5. `docs/EFFECT_CONTRACT_V1.md` near `:358` is rewrapped to the file's width, with no +word changed. diff --git a/.github/ISSUE_SPECS/TBD37-pin-the-browser-wasm-memory-as-a-ceiling.md b/.github/ISSUE_SPECS/TBD37-pin-the-browser-wasm-memory-as-a-ceiling.md new file mode 100644 index 000000000..6367d3e4e --- /dev/null +++ b/.github/ISSUE_SPECS/TBD37-pin-the-browser-wasm-memory-as-a-ceiling.md @@ -0,0 +1,66 @@ +# Pin the browser wasm memory as a ceiling + +Follow-up of #1060 (resource budgets, not exact counts), found by the batch 14a review of #1683. +Code anchors verified on the batch 14a fold branch `codex/controls-b14a-fold`. Issue number: +assigned at filing (written here as TBD37). Feature sprint mode (owner, 2026-10-13): one +implementer, normal tests, a batch review. + +## Product outcome + +A change that moves the browser module's linear memory by one page does not turn the gate red or +need a re-pin. Growth past a recorded budget still does. + +## Context + +- **The pin.** `hosts/host-web/tests/browser-v1/expected.json` pins the simd128 `memoryBytes` + exactly, in three places: `directOracle.simd128.initialStatus`, + `directOracle.simd128.beforeDisposeStatus` and the `beforeDisposeStatus` of the set-control + timeline row. `scripts/check-browser-expected-resources.py` compares them with the rest of the + printed document (`check_document`, the equality after the budgeted rows are removed), so one + moved page is red. +- **It moves by single pages.** #1632 moved it down one 64 KiB page (2,883,584 -> 2,818,048) and + #1683 moved it up one page (2,818,048 -> 2,883,584). Neither move was an unintended change; each + was a re-pin of three rows. +- **The rule.** `AGENTS.md`: "Resource budgets are ceilings." #1060 already made the + retained-memory rows ceilings (`CEILING_ROWS`, `resourceCeilings`, each with a printed + headroom). `memoryBytes` is the one resource figure left pinned exactly. It is a size, not a + claim. Linear memory grows by whole pages, so it moves in steps. +- **What stays exact.** The three PCM digests, the status fields other than `memoryBytes`, the + result transcripts and the exact rows are claims. They stay exact. + +## Decisions to take (the implementer proposes, the coordinator rules) + +- Where the ceiling lives: a `memoryCeilingBytes` key beside `resourceCeilings` in the simd128 leg + of `expected.json`, or one `memoryBytes` entry in `resourceCeilings` (which would need the + partition to cover a status field). +- The margin. Proposed: the measured value plus 2 pages (128 KiB, about 4.5 % of 2.8 MB), rounded to + a whole page, with the measurement and its date recorded beside the key in the commit message. + Two pages cover one move in each direction seen so far, and a second move in one batch is a + reason to look. The margin is raised only with its reason, as the other ceilings are. +- Whether the three rows share one ceiling (proposed: yes; they measure one module, and a + WebAssembly memory never shrinks, so the last status is the peak). + +## Objective gates + +1. `memoryBytes` is a ceiling: the checker removes it from the exact comparison, checks that all + three printed values are positive page multiples and at most the ceiling, and prints the headroom + in pages with its other budget lines. +2. The self-test stays red on a mutation: each of the three printed values one page over the ceiling + is caught, a zero is caught, and a missing or extra ceiling key is caught. The green case sits + at the ceiling. The existing mutation `move_status` (which adds 128 to a status field) moves to + a field that stays exact. +3. The current tree passes `scripts/check-browser-expected-resources.py` and its `--self-test`, with + the digests and exact rows unchanged. + +## Authorized paths + +- `hosts/host-web/tests/browser-v1/expected.json` +- `scripts/check-browser-expected-resources.py` (the checker and its self-test) + +## Dependencies + +- None. It is independent of any open batch. + +## Size + +A third of a day. diff --git a/docs/EFFECT_CONTRACT_V1.md b/docs/EFFECT_CONTRACT_V1.md index 23d2dd3b5..cc60ca2a8 100644 --- a/docs/EFFECT_CONTRACT_V1.md +++ b/docs/EFFECT_CONTRACT_V1.md @@ -355,7 +355,8 @@ and is an effect-identity change, never a contract cleanup. Per-lane audio, dela 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, clears histories and snaps every parameter -ramp in flight to its target (a runtime automation span names one sample and one value, so no span stays active). +ramp in flight to its target (a runtime automation span names one sample and one value, so no span +stays active). ## Stable diagnostics diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index 3eac545c4..4ebf8d19f 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -313,7 +313,10 @@ either curve; on each piece a side that is a constant `0` leaves it unchecked, a two constants need `H < L` (the static rule); two `linear` or two `exponential` pieces need `L > H·(1 + 2^-22)` at both ends; any other pair needs `min(L) > max(H)·(1 + 2^-22)` over the two ends, which is sufficient, not necessary, and refuses some pairs that never cross. The margin keeps -the rendered `f32` values strictly ordered. A failure is `control.automation.order`, once per lane +the rendered `f32` values strictly ordered. Each piece is checked widened by the cell's reach, 64 +samples (the grid lookahead of the stored curve and the filter's jump ramp), so stored automation +the render would carry across the order is refused here, never accepted and then refused in render. +A failure is `control.automation.order`, once per lane at the first failing piece, at the segment of the LPF entry (else the HPF entry) that defines its value there. The rule reads only the model, so it also judges a static filter value under an automated curve. From c2118907748466fcf7f0eeda5f559df1a57d5761 Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 20:14:02 +0000 Subject: [PATCH 09/10] Batch 14a review fold: code - #1685: the binding table charges its kept capacity and a later set's text as two peaks (measured 31,386 B against the old 26,640 B charge). - #1686: the descriptor fuzz loop's modulus is 5 again. - #1645: the order rule holds within render's reach, 128 samples (root ruling (a): each curve's 64-sample lead and lag); no per-comparison String clone; three asserts close the test gaps; two tests with no unique value are deleted. - #1683: the master takes effect at adoption; a boot-word master follows its strip through a live write and a rebuild; a solo and a master on one strip are two writes on both paths; the boot word and the row rules are documented side by side; the parity test reads typed fields; the registry names the master row. Co-Authored-By: Claude Opus 5.5 --- ...iltin-automation-targets-at-preparation.md | 6 + ...0-render-stored-input-filter-automation.md | 6 +- crates/capi/src/runtime/live_tests.rs | 84 -------- crates/compressor/src/lib.rs | 6 +- crates/conformance/tests/effect_contract.rs | 2 +- crates/control-plane/src/compile.rs | 46 +++- crates/control-plane/src/lib.rs | 2 +- .../tests/observation_bindings.rs | 53 +++++ crates/controls/src/automation.rs | 199 +++++++++++++----- crates/controls/src/strip.rs | 6 + crates/controls/tests/automation.rs | 80 ++++++- crates/host-core/tests/live_delta.rs | 52 ----- crates/protocol/src/model.rs | 44 ++++ docs/CONTROL_PROTOCOL_REGISTRY.md | 4 +- docs/SESSION_SCHEMA_V1.md | 7 +- hosts/host-web/src/lib.rs | 18 +- hosts/host-web/src/tests.rs | 187 ++++++++++++++++ hosts/host-web/tests/control_parity.rs | 61 +++++- .../miso-engine-v1-audio-worklet-host.d.ts | 8 +- sdk/src/browser/shipped-host.d.ts | 8 +- 20 files changed, 649 insertions(+), 230 deletions(-) diff --git a/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md b/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md index 80da9fbfd..7a981fbf3 100644 --- a/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md +++ b/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md @@ -343,5 +343,11 @@ Checked against `12d4a4f9d`. its value at 1014, 300 Hz, below the HPF's 900 Hz. `docs/SESSION_SCHEMA_V1.md` states the rule with the reach. - **#1660 must keep its reach at or below 64 samples**, or widen this rule in the same change. +- **The reach is 128 samples (coordinator, within the ruling).** Widening each curve by its + 64-sample reach means a rendered pair can read stored values up to 128 samples apart: render + leads a curve by up to 64 (grid lookahead) and lags it by up to 64 (jump ramp), on either filter. + `ORDER_REACH` is 128; with 64 the test `input_filter_curves_keep_their_order_within_renders_reach` + is red (an LPF fall 100 samples after the HPF's step is accepted). #1660 must keep each of its two + reaches at or below 64 samples, or widen this rule in the same change. - **Path.** The fold edits `crates/host-core/src/live_delta.rs`. That file is outside the original authorized paths. The coordinator approved the edit. diff --git a/.github/ISSUE_SPECS/1660-render-stored-input-filter-automation.md b/.github/ISSUE_SPECS/1660-render-stored-input-filter-automation.md index 9131ec36b..ca6e21723 100644 --- a/.github/ISSUE_SPECS/1660-render-stored-input-filter-automation.md +++ b/.github/ISSUE_SPECS/1660-render-stored-input-filter-automation.md @@ -228,5 +228,7 @@ Checked against `12d4a4f9d`. and `input_filter_order_diagnostics`, are superseded by those. - **#1660 keeps only the classification of input-filter edits on an automated strip** (the automation edits that are carried rebuilds, and the record rules of #1648). -- **The reach.** Root ruled on 2026-10-15 that #1645's order rule is checked widened by 64 samples. - #1660's render reach must stay at or below 64 samples, or the rule must widen in the same change. +- **The reach.** Root ruled on 2026-10-15 that #1645's order rule is checked widened by each + curve's 64-sample reach, which compares stored values up to 128 samples apart (`ORDER_REACH`). + #1660's grid lookahead and filter jump ramp must each stay at or below 64 samples, or the rule + must widen in the same change. diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 9921702c6..04e603338 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -4546,90 +4546,6 @@ fn builtin_automation_edit( } } -/// #1645 gate 7. On a playing engine, a transaction that only adds a builtin entry the row rule -/// refuses -- a mute that ramps (`control.automation.shape`), an HPF ride from 200 Hz that reaches -/// the static 1 kHz LPF (`control.automation.order`) -- is refused -/// with the compile rejection a rebuild's preparation gives: the revision, the committed model and -/// the reliable lane do not move, no candidate is prepared, and the following blocks are -/// bit-identical to an engine that never received it. -/// -/// Red if the classifier's row-rule check misses the shape or the order rule: the edit commits -/// live, acked, and its next rebuild refuses it. -#[test] -fn a_builtin_automation_the_rows_refuse_is_refused_before_any_ack() { - use session::{AutomationShape, ParameterUnit}; - let document = with_model(&eq_session(48_000), |model| { - let builtins = &mut model.tracks[0].builtins; - for lane in [&mut builtins.left, &mut builtins.right] { - lane.hpf_hz = 0.0; - lane.lpf_hz = 1_000.0; - } - }); - let mut rig = Rig::new(&document); - let mut untouched = Rig::new(&document); - for _ in 0..window(rig.latency(), 0, rig.quantum) { - assert_eq!(bits(&rig.step()), bits(&untouched.step()), "warm-up"); - } - let refused = [ - ( - builtin_automation_edit( - 6, - AutomationShape::Linear, - ParameterUnit::Linear, - (0.0, 1.0), - ), - "control.automation.shape", - ), - ( - builtin_automation_edit( - 3, - AutomationShape::Linear, - ParameterUnit::Hz, - (200.0, 1_000.0), - ), - "control.automation.order", - ), - ]; - for (edit, code) in refused { - let before = (refusal_state(&rig), rig.summary()); - assert_eq!( - rig.apply(core::slice::from_ref(&edit)), - crate::RESULT_COMPILE_REJECTED, - "{code}" - ); - let mut rejected = parse_session_json(&document).expect("model"); - protocol::apply_session_edit(&mut rejected, &edit).expect("the edit applies to the model"); - let rejected = session::canonical_session_json(&rejected).expect("canonical rejected"); - let Err(failure) = compile_children(&rejected, limits()) else { - panic!("{code}: the entry does not prepare") - }; - assert_eq!( - rig.last_error(), - failure.diagnostics, - "{code}: the refusal is preparation's diagnostic" - ); - assert!( - String::from_utf8_lossy(&failure.diagnostics).contains(code), - "{}", - String::from_utf8_lossy(&failure.diagnostics) - ); - assert_eq!( - (refusal_state(&rig), rig.summary()), - before, - "{code}: no revision, no candidate, no event" - ); - for _ in 0..3 { - let block = rig.block; - let expected = untouched.step(); - assert!( - expected.iter().any(|sample| *sample != 0.0), - "block {block}" - ); - assert_eq!(bits(&rig.step()), bits(&expected), "{code}: block {block}"); - } - } -} - /// #1645 fold. On a playing engine whose `eq0` stores an HPF ride from 100 to 900 Hz under a static /// 1 kHz LPF, a `SET_CONTROL` that moves the static LPF to 500 Hz (the automation table unchanged) /// is refused with the compile rejection preparation gives (`control.automation.order`): the diff --git a/crates/compressor/src/lib.rs b/crates/compressor/src/lib.rs index eeb1be39b..9bca89828 100644 --- a/crates/compressor/src/lib.rs +++ b/crates/compressor/src/lib.rs @@ -418,9 +418,9 @@ fn initial_defaults( /// Applies one lane's automation spans to its ramps. /// /// Validation is unchanged from V1 and deliberately strict: a span must be inside the prepared -/// automation capacity, a span at exactly `first_sample`, inside the parameter's domain, addressed to a single channel, in strictly -/// increasing `(parameter, channel)` order, and not a duplicate. Anything else is counted in -/// `invalid_spans` and ignored — never applied partially. +/// automation capacity, a span at exactly `first_sample`, inside the parameter's domain, addressed +/// to a single channel, in strictly increasing `(parameter, channel)` order, and not a duplicate. +/// Anything else is counted in `invalid_spans` and ignored — never applied partially. fn apply_automation( spans: &[PreparedAutomationSpan], first_sample: u64, diff --git a/crates/conformance/tests/effect_contract.rs b/crates/conformance/tests/effect_contract.rs index 9b7aa7f7f..d7cb8371b 100644 --- a/crates/conformance/tests/effect_contract.rs +++ b/crates/conformance/tests/effect_contract.rs @@ -234,7 +234,7 @@ fn every_faulty_mock_is_detected() { fn ten_thousand_descriptor_and_span_mutations_reject_without_panic() { for seed in 0..10_000_u32 { let mut descriptor: EffectDescriptor = DUAL_ACCUMULATOR_DELAY_DESCRIPTOR; - match seed % 4 { + match seed % 5 { 0 => descriptor.contract_major = 2, 1 => descriptor.state_layout_version = 0, 2 => descriptor.display_name = "", diff --git a/crates/control-plane/src/compile.rs b/crates/control-plane/src/compile.rs index 3039681dc..33ee5183d 100644 --- a/crates/control-plane/src/compile.rs +++ b/crates/control-plane/src/compile.rs @@ -692,6 +692,20 @@ pub fn observation_reserve_bytes( )) } +/// The binding table's charge alone ([`observation_binding_rows`]) for a control frame of +/// `frame_bytes` and `slots` observation slots, so a test can hold a measured table to it. +/// +/// # Errors +/// +/// The arithmetic overflowed. +#[cfg(feature = "test-support")] +pub fn observation_binding_table_bytes( + frame_bytes: u64, + slots: usize, +) -> Result { + Ok(observation_reserve(frame_bytes, slots)?.bindings_table) +} + /// The heap one `OBSERVATION_ENDED` event can own when it ends at most `keys` taps (#1584 D6a, /// #1685 D4): its key list, one `protocol::ObservationTapKey` each, and the text of the two IDs /// every key owns, at most [`admitted_text`]. `keys` is what the session can hold, the smaller of @@ -706,24 +720,36 @@ fn ended_event_ceiling(wire: ObservationWire, keys: usize) -> Result Result<(u64, u64), CompileFailure> { + let arithmetic = || failure(ResourceFault::Arithmetic); let binding = size_of::(); let key = size_of::(); - let table = peak_over_keys(wire, keys, |count| { - if count == 0 { 0 } else { count.max(4) } - .checked_mul(2)? - .checked_mul(binding)? - .checked_add(admitted_text(wire, count)?) - })?; + let capacity = if keys == 0 { 0 } else { keys.max(4) } + .checked_mul(2) + .and_then(|entries| entries.checked_mul(binding)) + .ok_or_else(arithmetic)?; + let text = peak_over_keys(wire, keys, |count| admitted_text(wire, count))?; + let table = u64::try_from(capacity) + .map_err(|_| failure(ResourceFault::Platform))? + .checked_add(text) + .ok_or_else(arithmetic)?; let transient = peak_over_keys(wire, keys, |count| { count .checked_mul(binding.checked_add(key)?)? diff --git a/crates/control-plane/src/lib.rs b/crates/control-plane/src/lib.rs index 9e6038e98..b10a2d5ee 100644 --- a/crates/control-plane/src/lib.rs +++ b/crates/control-plane/src/lib.rs @@ -73,7 +73,7 @@ pub use plan::{PlanQueries, PlanState}; #[cfg(feature = "test-support")] pub use compile::{ CapiResources, CompiledModelAdmission, LiveCatalogAdmission, LiveEpochResources, - observation_reserve_bytes, prepare_caps, validate_live_peak, + observation_binding_table_bytes, observation_reserve_bytes, prepare_caps, validate_live_peak, }; #[cfg(feature = "test-support")] pub use control::{ diff --git a/crates/control-plane/tests/observation_bindings.rs b/crates/control-plane/tests/observation_bindings.rs index c74e5ec29..726b7003b 100644 --- a/crates/control-plane/tests/observation_bindings.rs +++ b/crates/control-plane/tests/observation_bindings.rs @@ -1403,3 +1403,56 @@ fn a_batch_that_names_one_tap_twice_is_refused_at_the_second_naming() { .expect("each tap named once"); assert_eq!(rig.session.observation_armed(), cap); } + +/// The binding table's charge covers its high-water capacity and the text of a later armed set +/// together (#1685 review fold): `ObservationBindings::clear` never shrinks the `Vec`, so a table +/// that grew for many short IDs keeps that capacity while it later holds fewer, longer IDs. At a +/// 16 KiB frame, 129 short-ID taps grow the capacity to 256 entries; after they are disarmed, 51 +/// taps with two IDs of the grammar's longest fill the frame's text. +/// +/// Red if the table is charged one peak of capacity plus text over the key count (that charges +/// 258 entries and the little text 129 keys leave, or 102 entries and 51 keys' text, and the +/// measured table holds 256 entries and 51 keys' text). +#[test] +fn the_binding_table_charge_covers_its_kept_capacity_and_a_later_sets_text() { + const HIGH_WATER: usize = 129; + let frame = 16_384; + let charge = control_plane::observation_binding_table_bytes(frame, HIGH_WATER).expect("charge"); + let short = |tap: usize| TapKey { + strip: StableId::parse("s").expect("strip"), + section: TapSection::Inserts(StableId::parse("e").expect("effect")), + tap_id: u32::try_from(tap).expect("tap"), + }; + let longest = |tap: usize| TapKey { + strip: StableId::parse(&"s".repeat(session::STABLE_ID_MAXIMUM_BYTES)).expect("strip"), + section: TapSection::Inserts( + StableId::parse(&"e".repeat(session::STABLE_ID_MAXIMUM_BYTES)).expect("effect"), + ), + tap_id: u32::try_from(tap).expect("tap"), + }; + let mut table = controls::ObservationBindings::new(); + bench_support::alloc::current_thread_peak_reset(); + let mut highest = 0; + // After each change, every key the test built is owned by the table or freed, so the thread's + // live heap since the reset is the table's: its capacity and its bindings' ID text. + let mut measure = || { + highest = highest.max(bench_support::alloc::current_thread_peak().live_bytes); + }; + for tap in 0..HIGH_WATER { + table.set(short(tap), WINDOW_BLOCKS); + measure(); + } + for tap in 0..HIGH_WATER { + assert!(table.clear(&short(tap))); + measure(); + } + for tap in 0..51 { + table.set(longest(tap), WINDOW_BLOCKS); + measure(); + } + assert_eq!(table.len(), 51); + assert!( + highest <= charge, + "the table held {highest} bytes over a charge of {charge}" + ); +} diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index c3218d9d3..fd6971bbd 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -225,6 +225,17 @@ fn shape_fits(descriptor: &ControlDescriptor, segment: &AutomationSegment) -> bo /// after render rounds each ramped value once to `f32` no rendered pair ties or crosses. const ORDER_MARGIN: f64 = 1.0 + 1.0 / 4_194_304.0; +/// How far apart in time two stored filter values can be when render compares them, in node-time +/// samples: the order rule holds the HPF at each sample below the LPF at every sample this close on +/// either side (#1645 review fold, root's ruling (a)). #1637 gives an automated builtin filter cell +/// two reaches of 64 samples each, and neither is an exported constant: a grid ramp started at `τ` +/// aims at the curve's value 64 samples ahead (`automation::events` `target_at`), so render can +/// lead the stored curve by 64, and a discontinuity ramps over 64 samples, so render can lag it by +/// 64. The rendered value at `τ` therefore lies among the stored values of `[τ - 64, τ + 64]` on +/// each filter, and a rendered pair can read stored values up to 128 samples apart. A change to +/// either reach changes this constant with it. +const ORDER_REACH: u64 = 128; + /// One side of a lane's filter pair: its automation entry (with its segments in the evaluator's /// type) or its static value. enum Curve<'a> { @@ -235,22 +246,37 @@ enum Curve<'a> { }, } -/// What one side does on a sub-interval `[a, b)` of the cut time axis. +/// What one side does on a closed sub-interval `[a, b]` of the cut time axis (at `b` the left +/// limit). #[derive(Clone, Copy)] enum Piece { /// A hold, a `step` or a static value. Constant(f32), - /// A `linear` or `exponential` piece, with its values at `a` and (the left limit) at `b`. - Ramp { shape: Shape, at_a: f32, at_b: f32 }, + /// A `linear` or `exponential` piece: the one segment that defines the sub-interval. + Ramp(Segment), } impl Piece { - fn ends(self) -> (f64, f64) { + /// The value at `t`, a sample of the piece's closed sub-interval: the segment alone, so at its + /// end the value is its own `end_value`, never the start of a segment that begins there. + fn at(self, t: u64) -> f64 { match self { - Self::Constant(value) => (f64::from(value), f64::from(value)), - Self::Ramp { at_a, at_b, .. } => (f64::from(at_a), f64::from(at_b)), + Self::Constant(value) => f64::from(value), + Self::Ramp(segment) => { + f64::from(value_at(core::slice::from_ref(&segment), node_time(t))) + } } } + + /// Whether the piece is affine in time (a constant or a `linear` ramp). + fn linear(self) -> bool { + !matches!(self, Self::Ramp(segment) if segment.shape != Shape::Linear) + } + + /// Whether the piece's logarithm is affine in time (a constant or an `exponential` ramp). + fn exponential(self) -> bool { + !matches!(self, Self::Ramp(segment) if segment.shape != Shape::Exponential) + } } fn stored_segment(segment: &AutomationSegment) -> Segment { @@ -291,48 +317,75 @@ impl<'a> Curve<'a> { .saturating_sub(1) } - /// The piece on `[a, b)`, where no cut of this curve lies strictly inside; `b` is `None` on the + /// The piece on `[a, b]`, where no cut of this curve lies strictly inside; `b` is `None` on the /// last sub-interval, a hold. fn piece(&self, a: u64, b: Option) -> Piece { let segments = match self { Self::Static(value) => return Piece::Constant(*value), Self::Automated { segments, .. } => segments, }; - let at_a = value_at(segments, node_time(a)); - let segment = segments.get(Self::defining(segments, a)); - match (segment, b) { - (Some(segment), Some(b)) + match (segments.get(Self::defining(segments, a)), b) { + (Some(segment), Some(_)) if segment.start <= a && a < segment.end && segment.shape != Shape::Step => { - Piece::Ramp { - shape: segment.shape, - at_a, - // The segment alone: at its end the value is its own `end_value`, never the - // start of a segment that begins there. - at_b: value_at(core::slice::from_ref(segment), node_time(b)), - } + Piece::Ramp(*segment) } - _ => Piece::Constant(at_a), + _ => Piece::Constant(value_at(segments, node_time(a))), } } } -/// Whether the HPF piece stays below the LPF piece on the sub-interval (#1645 D2). -fn ordered(hpf: Piece, lpf: Piece) -> bool { +/// Whether the HPF piece on `[a, b]` stays below the LPF piece on `[c, d]` at every pair of +/// samples `(t, s)`, `t` in the first and `s` in the second, at most [`ORDER_REACH`] apart (#1645 +/// D2, widened by the review fold's ruling (a)). The pairs form a convex polygon in `(t, s)`. +fn ordered(hpf: Piece, (a, b): (u64, u64), lpf: Piece, (c, d): (u64, u64)) -> bool { + // No sample of one is within reach of the other. + if c > b.saturating_add(ORDER_REACH) || a > d.saturating_add(ORDER_REACH) { + return true; + } match (hpf, lpf) { - // "Off" on either side leaves the sub-interval unchecked. + // "Off" on either side leaves the pair unchecked. (Piece::Constant(off), _) | (_, Piece::Constant(off)) if off == 0.0 => true, // Render does not round a constant: the static rule. (Piece::Constant(h), Piece::Constant(l)) => h < l, - (Piece::Ramp { shape: hs, .. }, Piece::Ramp { shape: ls, .. }) if hs == ls => { - let (h_a, h_b) = hpf.ends(); - let (l_a, l_b) = lpf.ends(); - l_a > h_a * ORDER_MARGIN && l_b > h_b * ORDER_MARGIN + // `L(s) - H(t)·m` is affine in `(t, s)` (both pieces linear), or `ln L(s) - ln H(t)` is + // (both exponential), so its least value over the polygon is at a vertex. Every vertex + // is where two of its edges meet (`t` at `a` or `b`, `s` at `c` or `d`, `s - t` at `±R`); + // the candidates that leave the polygon are skipped, and any extra one inside it is only + // one more sample the rule must hold at. + _ if (hpf.linear() && lpf.linear()) || (hpf.exponential() && lpf.exponential()) => { + let r = ORDER_REACH; + [ + (a, c), + (a, d), + (b, c), + (b, d), + (a, a.saturating_sub(r)), + (a, a.saturating_add(r)), + (b, b.saturating_sub(r)), + (b, b.saturating_add(r)), + (c.saturating_sub(r), c), + (c.saturating_add(r), c), + (d.saturating_sub(r), d), + (d.saturating_add(r), d), + ] + .into_iter() + .filter(|&(t, s)| (a..=b).contains(&t) && (c..=d).contains(&s) && t.abs_diff(s) <= r) + .all(|(t, s)| lpf.at(s) > hpf.at(t) * ORDER_MARGIN) } + // Mixed pieces: the least LPF value over the samples within reach of the HPF piece above + // the greatest HPF value over the samples within reach of the LPF piece. Sufficient and + // not necessary, by design; each piece is monotone, so its extremes are at its ends. _ => { - let (h_a, h_b) = hpf.ends(); - let (l_a, l_b) = lpf.ends(); - l_a.min(l_b) > h_a.max(h_b) * ORDER_MARGIN + let (t_from, t_to) = ( + a.max(c.saturating_sub(ORDER_REACH)), + b.min(d.saturating_add(ORDER_REACH)), + ); + let (s_from, s_to) = ( + c.max(a.saturating_sub(ORDER_REACH)), + d.min(b.saturating_add(ORDER_REACH)), + ); + lpf.at(s_from).min(lpf.at(s_to)) > hpf.at(t_from).max(hpf.at(t_to)) * ORDER_MARGIN } } } @@ -341,13 +394,23 @@ fn ordered(hpf: Piece, lpf: Piece) -> bool { /// /// Each side's curve is its automation entry under the hold rule, or the constant of its static /// value when it is not automated; a lane with neither side automated is the static rule's. The -/// time axis is cut at every `start_sample` and `end_sample` of either curve; on each -/// sub-interval each side is one monotone piece, so its extremes lie at the ends: +/// time axis is cut at every `start_sample` and `end_sample` of either curve; on each closed +/// sub-interval each side is one monotone piece. +/// +/// Render moves a stored value by up to [`ORDER_REACH`] samples (a grid ramp aims ahead, a jump +/// ramps), so the rule holds the HPF at every sample `t` below the LPF at every sample `s` with +/// `|t - s| <= ORDER_REACH` (#1645 review fold, root's ruling (a)): stored automation is never +/// accepted now and crossed in render. Each HPF piece is judged against every LPF piece whose +/// sub-interval comes within reach of its own, over the pairs of samples within reach: /// /// - both constant: `H < L`, in `f32`; -/// - both `linear` or both `exponential`: `L > H·(1 + 2^-22)` at both ends (a linear difference -/// or a monotone ratio keeps the order inside); -/// - otherwise: `min(L) > max(H)·(1 + 2^-22)`, sufficient and not necessary, by design. +/// - both affine in time (a constant or `linear`), or both with an affine logarithm (a constant +/// or `exponential`): `L > H·(1 + 2^-22)` at every vertex of the polygon of pairs, which bounds +/// it everywhere inside; +/// - otherwise: `min(L) > max(H)·(1 + 2^-22)` over the samples of each piece within reach of the +/// other, sufficient and not necessary, by design. +/// +/// A constant "off" (`0`) on either side leaves its pairs unchecked. /// /// It reads only `model`, so a classifier can run it on a candidate model. Entries with their own /// fault (`faulted`) are not read: their curves may touch "off" inside a ramp. @@ -361,7 +424,7 @@ fn input_filter_order( let entry = |control: u32| { model.automation.iter().enumerate().find(|(_, automation)| { let target = &automation.target; - target.owner == Owner::Strip(strip.id.clone()) + matches!(&target.owner, Owner::Strip(owner) if owner == strip.id) && target.section == Section::Builtins && target.control == control && (target.lane == *lane || target.lane == Lane::Both) @@ -393,28 +456,52 @@ fn input_filter_order( } cuts.sort_unstable(); cuts.dedup(); - for (position, &a) in cuts.iter().enumerate() { - let b = cuts.get(position + 1).copied(); - if ordered(hpf.piece(a, b), lpf.piece(a, b)) { - continue; + // Sub-interval `k` is `[cuts[k], cuts[k + 1]]`; the last one runs on, a hold on both sides. + let span = |k: usize| { + let end = cuts.get(k + 1).copied(); + (cuts[k], end, end.unwrap_or(u64::MAX)) + }; + let pieces = |curve: &Curve<'_>| -> Vec { + (0..cuts.len()) + .map(|k| { + let (a, b, _) = span(k); + curve.piece(a, b) + }) + .collect() + }; + let (hpf_pieces, lpf_pieces) = (pieces(&hpf), pieces(&lpf)); + for (i, &h) in hpf_pieces.iter().enumerate() { + let (a, _, b) = span(i); + // The LPF sub-intervals within reach: from the first that ends at or after + // `a - ORDER_REACH` to the last that starts at or before `b + ORDER_REACH`. + let first = cuts + .partition_point(|&cut| cut.saturating_add(ORDER_REACH) < a) + .saturating_sub(1); + let last = cuts.partition_point(|&cut| cut <= b.saturating_add(ORDER_REACH)); + for (j, &l) in lpf_pieces.iter().enumerate().take(last).skip(first) { + let (c, _, d) = span(j); + if ordered(h, (a, b), l, (c, d)) { + continue; + } + // The LPF entry when the LPF is automated (at the LPF's sub-interval), else the HPF + // entry (at the HPF's). + let (reported, at) = if matches!(lpf, Curve::Automated { .. }) { + (&lpf, c) + } else { + (&hpf, a) + }; + let Curve::Automated { entry, segments } = reported else { + unreachable!("one side is automated") + }; + return Some(ControlDiagnostic { + code: ControlError::AutomationOrder.code(), + path: format!( + "$.automation[id={}].segments[{}]", + entry.id.as_str(), + Curve::defining(segments, at) + ), + }); } - // The LPF entry when the LPF is automated, else the HPF entry. - let reported = if matches!(lpf, Curve::Automated { .. }) { - &lpf - } else { - &hpf - }; - let Curve::Automated { entry, segments } = reported else { - unreachable!("one side is automated") - }; - return Some(ControlDiagnostic { - code: ControlError::AutomationOrder.code(), - path: format!( - "$.automation[id={}].segments[{}]", - entry.id.as_str(), - Curve::defining(segments, a) - ), - }); } None } diff --git a/crates/controls/src/strip.rs b/crates/controls/src/strip.rs index bd1ff28b9..13567704d 100644 --- a/crates/controls/src/strip.rs +++ b/crates/controls/src/strip.rs @@ -142,6 +142,12 @@ pub(crate) static ROWS: [StripRow; 6] = [ // a submix too: a bus whose strip carries a limiter can be the master (issue #1208 D3). At // most one strip is the master; designating one ends the other's designation. It moves no // render value, so it has no ramp and no family that render reads. + // + // The row is a core designation, valid on every host and with no observation tap: the C ABI + // has no taps, and a browser booted with no observation taps takes the write too. Without a + // tap on the strip the master's reading (`masterGrDb`) is absent, never zero. The browser's + // boot word (`WebBootOptions::live_control_master_track_plus_one`) keeps its stricter boot + // rule, refused unless observation taps are configured, until its successor retires it. StripRow { id: MASTER, on_submix: true, diff --git a/crates/controls/tests/automation.rs b/crates/controls/tests/automation.rs index 636dd5b31..b02c9927d 100644 --- a/crates/controls/tests/automation.rs +++ b/crates/controls/tests/automation.rs @@ -575,8 +575,8 @@ fn input_filter_curves_keep_the_hpf_below_the_lpf() { ), Vec::new() ); - // The same LPF ride under an HPF that stays on is refused in the gap's hold: the LPF entry's - // segment 0 defines its value there. + // The same LPF ride under an HPF that stays on is refused on the LPF ride's sub-interval + // [480, 960): the LPF entry's segment 0 defines its value there. assert_eq!( judged_with( with_static_lpf(1_000.0), @@ -591,4 +591,80 @@ fn input_filter_curves_keep_the_hpf_below_the_lpf() { let ride = || vec![hpf("h", vec![ramp(Linear, Hz, span, (100.0, 900.0))])]; assert_eq!(judged_with(with_static_lpf(1_000.0), ride()), Vec::new()); assert_eq!(judged_with(with_static_lpf(500.0), ride()), order("h", 0)); + + // Two constants that tie are refused: the static rule is strict. Red if `<` becomes `<=`. + assert_eq!( + judged_with( + with_static_lpf(1_000.0), + vec![hpf("h", vec![ramp(Step, Hz, span, (1_000.0, 1_000.0))])] + ), + order("h", 0) + ); + // A ride that ends one `f32` step under a static LPF is ordered in `f32` and refused within + // the margin, which render's rounding could close. Red if the margin is removed. + let under = f32::from_bits(1_000.0_f32.to_bits() - 1); + assert_eq!( + judged_with( + with_static_lpf(1_000.0), + vec![hpf("h", vec![ramp(Linear, Hz, span, (100.0, under))])] + ), + order("h", 0) + ); + // Two parallel `exponential` rides, the LPF an octave above, are accepted, as two `linear` + // ones are. Red if an exponential pair falls through to the mixed rule (its extremes overlap). + assert_eq!( + judged_with( + with_static_lpf(1_000.0), + vec![ + hpf("h", vec![ramp(Exponential, Hz, span, (100.0, 800.0))]), + lpf("l", vec![ramp(Exponential, Hz, span, (200.0, 1_600.0))]), + ] + ), + Vec::new() + ); +} + +/// Root's ruling (a) on #1645 (review fold): render can lead a stored filter value by 64 samples +/// (#1637's grid lookahead) and lag it by 64 (the filter's jump ramp), so a rendered pair can read +/// stored values up to 128 samples apart, and the rule holds the HPF at each sample below the LPF +/// at every sample within 128 samples of it. An HPF that holds 900 Hz on `[0, 1000)` and then +/// steps to 100 Hz, under an LPF that falls linearly from 1,000 Hz to 300 Hz on `[1000, 1010)`, is +/// ordered at every single sample but crosses in render: the LPF reaches 300 Hz before the HPF's +/// jump ramp has left 900 Hz. +/// +/// Red if the rule compares the two curves at the same sample only (the counterexample is then +/// accepted), if the reach is 64 rather than 128 (the same fall a hundred samples later, still +/// within one lead plus one lag of the HPF's 900 Hz, is then accepted), or if the reach is +/// unbounded (the fall two hundred samples later is then refused). +#[test] +fn input_filter_curves_keep_their_order_within_renders_reach() { + use AutomationShape::{Linear, Step}; + use ParameterUnit::Hz; + let pair = |fall: u64| { + vec![ + hpf("h", vec![ramp(Step, Hz, (0, 1_000), (900.0, 100.0))]), + lpf( + "l", + vec![ramp(Linear, Hz, (fall, fall + 10), (1_000.0, 300.0))], + ), + ] + }; + assert_eq!( + judged_with(with_static_lpf(1_000.0), pair(1_000)), + [( + "control.automation.order", + "$.automation[id=l].segments[0]".to_owned() + )] + ); + assert_eq!( + judged_with(with_static_lpf(1_000.0), pair(1_100)), + [( + "control.automation.order", + "$.automation[id=l].segments[0]".to_owned() + )] + ); + assert_eq!( + judged_with(with_static_lpf(1_000.0), pair(1_200)), + Vec::new() + ); } diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index 497954262..2b8342be3 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -1221,58 +1221,6 @@ fn a_mode_switch_that_strands_an_automation_needs_a_rebuild() { ); } -/// #1645 gate 7. A delta that only adds a builtin entry the row rule refuses routes to the rebuild, -/// whose preparation refuses it: a fader ride in `linear` units (`control.domain`), a mute that -/// ramps (`control.automation.shape`) and an HPF ride that reaches a static LPF -/// (`control.automation.order`). The same delta with a valid fader ride stays live. -/// -/// Red if the classifier's row-rule check misses the shape or order rule: the edit commits live, -/// acked, and its next rebuild refuses it. -#[test] -fn an_automation_the_builtin_rows_refuse_needs_a_rebuild() { - let mut declared = two_tracks(); - declared.tracks[0].builtins.left.lpf_hz = 1_000.0; - declared.tracks[0].builtins.right.lpf_hz = 1_000.0; - let current = normalized(&declared); - type Make = fn(&mut Automation); - let refused: [(&str, Make); 3] = [ - ("fader in linear units", |ride| { - ride.segments[0].unit = ParameterUnit::Linear; - }), - ("mute ramp", |ride| { - ride.target.control = 6; - ride.segments[0].unit = ParameterUnit::Linear; - ride.segments[0].start_value = 0.0; - ride.segments[0].end_value = 1.0; - }), - ("HPF ride to the LPF", |ride| { - ride.target.control = 3; - ride.segments[0].unit = ParameterUnit::Hz; - ride.segments[0].start_value = 200.0; - ride.segments[0].end_value = 1_000.0; - }), - ]; - for (name, make) in refused { - assert_eq!( - classify(¤t, |next| { - let mut ride = fader_ride(next); - make(&mut ride); - next.automation.push(ride); - }), - Err(LiveRebuild::AutomationTarget), - "{name}" - ); - } - assert!( - classify(¤t, |next| { - let ride = fader_ride(next); - next.automation.push(ride); - }) - .is_ok(), - "a valid fader ride stays live" - ); -} - /// #1645 fold. A delta that only moves a static LPF under a stored HPF ride (the automation table /// unchanged) routes to the rebuild when the order rule refuses `next`: an LPF of 500 Hz under a /// ride to 900 Hz. An LPF of 2 kHz, an LPF edit on a strip with no filter automation, and the same diff --git a/crates/protocol/src/model.rs b/crates/protocol/src/model.rs index 9ab9d264f..5f061b320 100644 --- a/crates/protocol/src/model.rs +++ b/crates/protocol/src/model.rs @@ -1844,6 +1844,50 @@ mod tests { ); } + /// #1683 review fold: a solo and a master write of one strip in one transaction are two monitor + /// writes, on the in-place path and on the full path alike. The rule that a later write + /// replaces an earlier one holds per strip and row, never per strip. + /// + /// Red if either path keys the replacement by strip alone: the master write would then drop + /// the solo, which the response would still acknowledge. + #[test] + fn a_solo_and_a_master_of_one_strip_are_two_monitor_writes_on_both_paths() { + let store = store(); + let track = store.compiled().normalized_model().tracks[0].id.clone(); + let monitor = |control: u32| { + SessionEdit::set_control( + controls::ControlAddress { + owner: Owner::Strip(track.clone()), + section: Section::Own, + control, + lane: Lane::Both, + }, + 1.0, + None, + ) + }; + let edits = [monitor(2), monitor(4)]; + let expected = [controls::MonitorRow::Solo, controls::MonitorRow::Master].map(|row| { + controls::MonitorWrite { + strip: track.clone(), + row, + on: true, + ramp: None, + } + }); + let revision = ExpectedRevision::Exact(store.revision()); + let in_place = store + .prepare_transaction_in_place(revision, &edits) + .expect("prepares in place"); + assert!(in_place.is_in_place() && in_place.is_monitor_only()); + assert_eq!(in_place.monitor_edits(), expected, "in place"); + let full = store + .prepare_transaction_with_monitor(revision, &edits) + .expect("prepares"); + assert!(!full.is_in_place()); + assert_eq!(full.monitor_edits(), expected, "full"); + } + /// #1612 D2. `set_control_rows` names the rows a transaction of `SetControl`s wrote, in /// transaction order, and is `None` as soon as one edit is another kind. A write to a monitor /// row is not a model write, so it is not in the list, and a transaction of such writes alone diff --git a/docs/CONTROL_PROTOCOL_REGISTRY.md b/docs/CONTROL_PROTOCOL_REGISTRY.md index fa6e84e43..832011314 100644 --- a/docs/CONTROL_PROTOCOL_REGISTRY.md +++ b/docs/CONTROL_PROTOCOL_REGISTRY.md @@ -20,7 +20,7 @@ The automation queue is **retired** with its last producer and consumer (#1629): Status IDs are: `0` OK, `1` malformed frame, `2` unsupported version, `3` unsupported message, `4` unknown required field, `5` invalid field, `6` limit exceeded, `7` revision conflict, `8` revision exhausted, `9` request ID reuse, `10` replay expired, `11` backpressure, `12` validation failed, `13` not found, `14` unavailable, `17` PCM forbidden, `18` internal; `15` and `16` are retired (#1627) and never reallocated. -`QueueKind` is control command `1`, reliable response `3`, reliable event `4`, telemetry `5`, replay cache `6`. Diagnostic severity is info `1`, warning `2`, error `3`. Transport state is stopped `1` or playing `2`. Parameter enums are: rack inserts/builtins/console `2`/`4`/`5` (decision 12, #1093: `inserts` kept the retired `dynamic` code and `console` is appended; `1` (SIMD1) and `3` (SIMD2) are retired and refused, never reallocated; `builtins` is the strip's own fixed section, appended by issue #178; the same table is the session edits' rack name and an automation target's rack); channel left/right/both `1..3`; unit dB/Hz/ms/samples/linear/ratio `1..6`. Transaction edit path (`0003` response field `2`, #1313, #1559) is live/model_only/rebuild/monitor `1..4`: records on the running plan's live lanes, nothing for render to do, a successor plan prepared and published, and writes to the host's monitor overlay only (solo: no model change, no revision change, no `SESSION_COMMITTED`); zero and every unallocated value reject. Meter component is left/right/aggregate `1..3`; meter flags are valid/clipped/held bits `0..2`. All unallocated bits reject. +`QueueKind` is control command `1`, reliable response `3`, reliable event `4`, telemetry `5`, replay cache `6`. Diagnostic severity is info `1`, warning `2`, error `3`. Transport state is stopped `1` or playing `2`. Parameter enums are: rack inserts/builtins/console `2`/`4`/`5` (decision 12, #1093: `inserts` kept the retired `dynamic` code and `console` is appended; `1` (SIMD1) and `3` (SIMD2) are retired and refused, never reallocated; `builtins` is the strip's own fixed section, appended by issue #178; the same table is the session edits' rack name and an automation target's rack); channel left/right/both `1..3`; unit dB/Hz/ms/samples/linear/ratio `1..6`. Transaction edit path (`0003` response field `2`, #1313, #1559) is live/model_only/rebuild/monitor `1..4`: records on the running plan's live lanes, nothing for render to do, a successor plan prepared and published, and writes to the host's monitor overlay only (solo and master: no model change, no revision change, no `SESSION_COMMITTED`); zero and every unallocated value reject. Meter component is left/right/aggregate `1..3`; meter flags are valid/clipped/held bits `0..2`. All unallocated bits reject. ## Common forms @@ -140,7 +140,7 @@ 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`, `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. +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` (the monitor rows, `solo` and `master` (#1683), are held by the host and have no session writer: the refusal belongs to the one-call path only, `controls::set` on a monitor row; the prepared path takes a write of either 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. diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index 4ebf8d19f..95d43eaae 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -313,9 +313,10 @@ either curve; on each piece a side that is a constant `0` leaves it unchecked, a two constants need `H < L` (the static rule); two `linear` or two `exponential` pieces need `L > H·(1 + 2^-22)` at both ends; any other pair needs `min(L) > max(H)·(1 + 2^-22)` over the two ends, which is sufficient, not necessary, and refuses some pairs that never cross. The margin keeps -the rendered `f32` values strictly ordered. Each piece is checked widened by the cell's reach, 64 -samples (the grid lookahead of the stored curve and the filter's jump ramp), so stored automation -the render would carry across the order is refused here, never accepted and then refused in render. +the rendered `f32` values strictly ordered. Render can lead a stored filter value by 64 samples +(the grid lookahead) and lag it by 64 (the filter's jump ramp), so the HPF at each sample is held +below the LPF at every sample within 128 samples of it: stored automation the render would carry +across the order is refused here, never accepted and then refused in render. A failure is `control.automation.order`, once per lane at the first failing piece, at the segment of the LPF entry (else the HPF entry) that defines its value there. The rule reads only the model, so it also judges a static filter value under an diff --git a/hosts/host-web/src/lib.rs b/hosts/host-web/src/lib.rs index e421e0355..cc1bedd3a 100644 --- a/hosts/host-web/src/lib.rs +++ b/hosts/host-web/src/lib.rs @@ -1101,6 +1101,12 @@ pub struct WebBootOptions { /// /// Boot v1 has no structural master bus, so `masterGrDb` is a designation rather than a discovery. /// Plus one because zero has to keep meaning "unset" in a word every V1 writer already zeroes. + /// + /// Two rules stand side by side until a successor retires this word (#1683). The word is + /// refused at boot (`web.options.live_controls`) unless `live_control_observation_taps` is + /// nonzero. The core's `master` monitor row, written by `SET_CONTROL`, is a designation valid + /// on every host with or without observation taps (the C ABI has none); without a tap on the + /// strip, `masterGrDb` is `null`. The word seeds that row at boot by the strip's ID. pub live_control_master_track_plus_one: u64, } @@ -1648,15 +1654,21 @@ impl ReadyOwnership { result } - /// Point every plan's tables the host holds at the master strip the core designates now - /// (#1683): the rendering plan's and each replacement's that waits for its adoption, each by + /// Point the newest plan's tables at the master strip the core designates now (#1683): each + /// replacement's that waits for its adoption, else the rendering plan's, each by /// the strip's ID in that plan's own strip order, so a strip added or removed before the /// master never moves the reading to another strip. A plan that does not hold the strip has no /// master. The designation moves no render value; the next poll folds the master reading from /// it. Control side only, as [`Self::stamp_structure_epoch`]. fn sync_master(&mut self) { let master = self.control.master_strip(); - let shapes = core::iter::once(&mut *self.shape).chain( + // While a replacement waits for its adoption, the designation rides it: the rendering + // plan keeps its own until the block that adopts the replacement swaps the tables (#1683 + // review fold), so a rebuild that removes the master never clears the reading of the + // plan still rendering. + let waiting = self.pending_shapes.iter().any(|entry| !entry.adopted); + let rendering = (!waiting).then_some(&mut *self.shape); + let shapes = rendering.into_iter().chain( self.pending_shapes .iter_mut() .filter(|entry| !entry.adopted) diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 0ae858975..dbdec3531 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -8216,6 +8216,193 @@ mod structural_apply { assert!(word > 0.0); assert_eq!(master, None, "no designation reads absent"); } + + impl Rig { + /// The strips whose `master` monitor row is on in the control list, read through the + /// production control entry by the rows' typed fields. + fn listed_masters(&mut self, request: u64) -> Vec { + let codec = ProtocolCodec::default(); + let mut masters = Vec::new(); + let (mut epoch, mut after, mut request) = (0, 0, request); + loop { + let mut bytes = vec![0_u8; codec.limits().max_frame_bytes]; + let length = codec + .encode_command_frame_into( + &TypedCommandFrame { + request_id: RequestId::new(request).expect("nonzero request"), + expected_revision: ExpectedRevision::Any, + payload: CommandPayload::ControlListGet( + &protocol::ControlListRequest { + structure_epoch: epoch, + after_handle: after, + limit: 256, + owner: None, + }, + ), + }, + &mut bytes, + ) + .expect("a control list frame"); + bytes.truncate(length); + request += 1; + let (result, response) = self.entry(&bytes); + assert_eq!(result, RESULT_OK, "the control plane answers the list"); + let mut fields = [0_u16; 8192]; + let DecodedTypedResponseFrame::Success { + payload: DecodedSuccessResponsePayload::ControlListPage(page), + .. + } = codec + .decode_typed_response(&response, &mut DecodeScratch::new(&mut fields)) + .expect("a control list page") + else { + panic!("expected a control list page") + }; + masters.extend(page.entries.iter().filter_map(|entry| match &entry.body { + protocol::ControlBody::Control(row) + if row.name == "master" && row.value == 1.0 => + { + entry.address.owner_id.clone() + } + _ => None, + })); + if page.eof { + return masters; + } + epoch = page.structure_epoch; + after = page.last_handle; + } + } + } + + /// The observation fixture's three tracks (`t0`, `t1`, `t2`), booted with the boot word + /// designating track `master` (#1683). + fn boot_designated(master: u64) -> (SessionModel, Rig) { + let model = parse_session_json(include_str!( + "../../../fixtures/session/v1/observation-frame-shape.json" + )) + .expect("the observation fixture parses"); + let options = WebBootOptions { + source_ring_frames: QUANTUM * 4, + live_control_meter_blocks: 2, + live_control_observation_taps: 4, + live_control_master_track_plus_one: master + 1, + ..boot_options(QUANTUM) + }; + let rig = Rig::boot(&model, options, None); + (model, rig) + } + + /// #1683 review fold: a master the boot word designates is the core's `master` row of that + /// strip, held by ID from boot on. A live `SET_CONTROL` of another row and then a rebuild that + /// adds a track whose ID sorts first leave the row on `t1` and move the header's index with it. + /// + /// Test value: red if the boot word seeds the overlay by index only (a live write or the + /// rebuild would then leave the reading on strip 1, now `t0`), or if a live write re-seeds it. + #[test] + fn a_boot_designated_master_follows_its_strip_through_a_live_write_and_a_rebuild() { + let (model, mut rig) = boot_designated(1); + assert_eq!(rig.host.meter_header().master_track_plus_one, 2); + assert_eq!(rig.listed_masters(1), ["t1"]); + + let fader = SessionEdit::SetControl { + expected_effect: None, + address: controls::ControlAddress { + owner: controls::Owner::Strip(StableId::parse("t2").expect("strip ID")), + section: controls::Section::Builtins, + control: 5, + lane: session::ParameterChannel::Both, + }, + value: -6.0, + ramp_samples: None, + }; + let live = rig.apply(&[fader], 1_000); + assert!( + matches!( + live, + Outcome::Applied { + path: EditPath::Live, + .. + } + ), + "{live:?}" + ); + assert_eq!(rig.host.render_next(), RESULT_OK); + assert_eq!(rig.host.meter_header().master_track_plus_one, 2); + assert_eq!(rig.listed_masters(1_001), ["t1"]); + + let mut track = model.tracks[0].clone(); + let mut route = model.routes[0].clone(); + track.id = StableId::parse("a0").expect("a track ID"); + route.id = StableId::parse("a0-main").expect("a route ID"); + let RouteSource::Track { track_id, .. } = &mut route.source else { + panic!("the fixture routes a track") + }; + *track_id = track.id.clone(); + assert!(matches!( + rig.apply( + &[ + SessionEdit::UpsertTrack { track }, + SessionEdit::UpsertRoute { route }, + ], + 2_000 + ), + Outcome::Applied { + path: EditPath::Rebuild, + .. + } + )); + assert_eq!(rig.host.render_next(), RESULT_OK, "the rebuild is adopted"); + assert_eq!( + rig.host.meter_header().master_track_plus_one, + 3, + "t1 is strip 2 now, and still the master" + ); + assert_eq!(rig.listed_masters(2_001), ["t1"]); + } + + /// #1683 review fold: a rebuild that removes the master strip ends the designation in the + /// committed model and the replacement's tables at once, but the plan still rendering keeps its + /// master until the block that adopts the replacement. + /// + /// Test value: red if the commit rewrites the rendering plan's header (its reading would vanish + /// before render has moved to the plan without the strip), or if the adopted plan keeps a + /// master the session no longer has. + #[test] + fn a_rebuild_that_removes_the_master_keeps_the_rendering_plans_master_until_adoption() { + let (_, mut rig) = boot_designated(0); + assert_eq!(rig.host.meter_header().master_track_plus_one, 1); + assert!(matches!( + rig.apply( + &[ + SessionEdit::RemoveRoute { + route_id: StableId::parse("t0-main").expect("a route ID"), + }, + SessionEdit::RemoveTrack { + track_id: StableId::parse("t0").expect("a track ID"), + }, + ], + 1 + ), + Outcome::Applied { + path: EditPath::Rebuild, + .. + } + )); + assert!(rig.pending(), "the replacement waits for its adoption"); + assert_eq!( + rig.host.meter_header().master_track_plus_one, + 1, + "the rendering plan keeps its master until adoption" + ); + assert_eq!(rig.listed_masters(2), Vec::::new()); + assert_eq!(rig.host.render_next(), RESULT_OK, "the rebuild is adopted"); + assert!(!rig.pending()); + assert_eq!( + rig.host.meter_header().master_track_plus_one, + 0, + "the adopted plan has no master" + ); + } } /// The encoded bytes of the one `OBSERVATION_ENDED` frame that ends the insert taps keyed by diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 9b6e631e6..568f61100 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -1546,6 +1546,48 @@ fn dump( (rows, snapshot) } +/// The owners of the control list's `master` rows that are on (#1683), read by the rows' typed +/// fields. +fn listed_masters( + mut send: impl FnMut(CommandPayload<'_>, ExpectedRevision) -> Vec, +) -> Vec { + let mut masters = Vec::new(); + let (mut epoch, mut after) = (0, 0); + loop { + let request = ControlListRequest { + structure_epoch: epoch, + after_handle: after, + limit: 256, + owner: None, + }; + let bytes = send( + CommandPayload::ControlListGet(&request), + ExpectedRevision::Any, + ); + let mut fields = [0_u16; 4096]; + let DecodedTypedResponseFrame::Success { + payload: DecodedSuccessResponsePayload::ControlListPage(page), + .. + } = ProtocolCodec::default() + .decode_typed_response(&bytes, &mut DecodeScratch::new(&mut fields)) + .expect("a control page") + else { + panic!("expected a control list page") + }; + masters.extend(page.entries.iter().filter_map(|entry| match &entry.body { + protocol::ControlBody::Control(row) if row.name == "master" && row.value == 1.0 => { + entry.address.owner_id.clone() + } + _ => None, + })); + if page.eof { + return masters; + } + epoch = page.structure_epoch; + after = page.last_handle; + } +} + /// One run of `steps` on both legs. Returns the browser's blocks and the final superseded count /// (equal on both legs, asserted here). fn run(rate: u32, steps: &[Step]) -> (Vec>, u64) { @@ -2836,17 +2878,22 @@ fn both_hosts_designate_the_master_strip_after_an_empty_boot_alike() { let (c_rows, c_snapshot) = dump(|payload, expected| c_abi.send(payload, expected)); assert_eq!(b_rows, c_rows, "step {step}: the control pages agree"); assert_eq!(b_snapshot, c_snapshot, "step {step}: the snapshots agree"); - // The listed masters: the owner of each `master` row whose value is on. - let designated: Vec<&str> = b_rows - .iter() - .filter(|row| row.contains("name: \"master\"") && row.contains("value: 1.0 }")) - .filter_map(|row| row.split("owner_id: Some(\"").nth(1)?.split('"').next()) - .collect(); + // The listed masters: the owner of each `master` row whose value is on, by the rows' + // typed fields, on each host. let expected: &[&str] = match step { 1 => &["t1"], 2..=4 => &["bus"], _ => &[], }; - assert_eq!(designated, expected, "step {step}: the listed master"); + assert_eq!( + listed_masters(|payload, expected| browser.command(payload, expected)), + expected, + "step {step}: the browser's listed master" + ); + assert_eq!( + listed_masters(|payload, expected| c_abi.send(payload, expected)), + expected, + "step {step}: the C ABI's listed master" + ); } } 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 1469234d3..33125f944 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 @@ -254,7 +254,8 @@ export const enum MisoCommandReason { /// The subscription would leave more taps armed than the engine can end honestly in one /// `OBSERVATION_ENDED` event in one control frame (issue 1685: the event's exact encoded bytes /// against the browser's 16 KiB frame). `rejectedIndex` names the subscription that crossed it, - /// after every unsubscribe of the same submission is counted; nothing was admitted. The result is `RESULT_UNSUPPORTED`. + /// 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. @@ -714,7 +715,10 @@ export interface MisoWebBootOptions { /// Plus one because zero has to keep meaning "unset". Requires /// `liveControlObservationTaps !== 0n`. This word designates the first plan only (issue /// 1683): after boot the app sets or moves the master with the strip's `master` monitor row - /// (`SET_CONTROL`), the core holds it by strip ID, and every later plan follows it. + /// (`SET_CONTROL`), the core holds it by strip ID, and every later plan follows it. The row has + /// no tap requirement: it is valid on every host (the C ABI has no taps), and with no tap on the + /// strip `masterGrDb` is `null`. Only this boot word keeps the stricter rule, until its + /// successor retires it. liveControlMasterTrackPlusOne: bigint; /// One optional prepared graph boundary; `null` retains no capture storage. spectrum?: MisoSpectrumBootOptions | null; diff --git a/sdk/src/browser/shipped-host.d.ts b/sdk/src/browser/shipped-host.d.ts index 1469234d3..33125f944 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -254,7 +254,8 @@ export const enum MisoCommandReason { /// The subscription would leave more taps armed than the engine can end honestly in one /// `OBSERVATION_ENDED` event in one control frame (issue 1685: the event's exact encoded bytes /// against the browser's 16 KiB frame). `rejectedIndex` names the subscription that crossed it, - /// after every unsubscribe of the same submission is counted; nothing was admitted. The result is `RESULT_UNSUPPORTED`. + /// 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. @@ -714,7 +715,10 @@ export interface MisoWebBootOptions { /// Plus one because zero has to keep meaning "unset". Requires /// `liveControlObservationTaps !== 0n`. This word designates the first plan only (issue /// 1683): after boot the app sets or moves the master with the strip's `master` monitor row - /// (`SET_CONTROL`), the core holds it by strip ID, and every later plan follows it. + /// (`SET_CONTROL`), the core holds it by strip ID, and every later plan follows it. The row has + /// no tap requirement: it is valid on every host (the C ABI has no taps), and with no tap on the + /// strip `masterGrDb` is `null`. Only this boot word keeps the stricter rule, until its + /// successor retires it. liveControlMasterTrackPlusOne: bigint; /// One optional prepared graph boundary; `null` retains no capture storage. spectrum?: MisoSpectrumBootOptions | null; From 64bd5f39f4347210b07874cf90441fddeeb71970 Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 20:46:51 +0000 Subject: [PATCH 10/10] Number #1691 (browser wasm memory as a ceiling) and name #1690 in #1683's record Co-Authored-By: Claude Opus 5.5 --- .../1683-designate-the-browser-master-strip-after-boot.md | 2 +- ...ling.md => 1691-pin-the-browser-wasm-memory-as-a-ceiling.md} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename .github/ISSUE_SPECS/{TBD37-pin-the-browser-wasm-memory-as-a-ceiling.md => 1691-pin-the-browser-wasm-memory-as-a-ceiling.md} (98%) diff --git a/.github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md b/.github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md index d41a2da88..e6c0a2382 100644 --- a/.github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md +++ b/.github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md @@ -120,7 +120,7 @@ before the master, it named another strip, or refused the rebuild). **Departures (batch 14a, appended).** "Departures: none" above needs one line. The C ABI lists, accepts and reads back the `master` row, but nothing on the C ABI consumes it yet. Under decision 17 C1 this is correct: the designation is core state, readable on both hosts. Its reading on the C ABI -is the successor TBD36 ("Read the master strip's meter on the C ABI"). +is the successor #1690 ("Read the master strip's meter on the C ABI"). **Review fold (batch 14a).** diff --git a/.github/ISSUE_SPECS/TBD37-pin-the-browser-wasm-memory-as-a-ceiling.md b/.github/ISSUE_SPECS/1691-pin-the-browser-wasm-memory-as-a-ceiling.md similarity index 98% rename from .github/ISSUE_SPECS/TBD37-pin-the-browser-wasm-memory-as-a-ceiling.md rename to .github/ISSUE_SPECS/1691-pin-the-browser-wasm-memory-as-a-ceiling.md index 6367d3e4e..1f8b47575 100644 --- a/.github/ISSUE_SPECS/TBD37-pin-the-browser-wasm-memory-as-a-ceiling.md +++ b/.github/ISSUE_SPECS/1691-pin-the-browser-wasm-memory-as-a-ceiling.md @@ -2,7 +2,7 @@ Follow-up of #1060 (resource budgets, not exact counts), found by the batch 14a review of #1683. Code anchors verified on the batch 14a fold branch `codex/controls-b14a-fold`. Issue number: -assigned at filing (written here as TBD37). Feature sprint mode (owner, 2026-10-13): one +assigned at filing (written here as #1691). Feature sprint mode (owner, 2026-10-13): one implementer, normal tests, a batch review. ## Product outcome