From 98338cf761cda203f928f3eb6a5f5db68e4e2ad2 Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 12:50:12 +0000 Subject: [PATCH 01/12] Batch 13 docs: placeholder specs from the owner's answers (TBD29 sources are mono or stereo; TBD30 an empty session boots on both hosts) Co-Authored-By: Claude Opus 5.5 --- ...se-a-source-with-more-than-two-channels.md | 54 +++++++++++++++++++ ...D30-boot-an-empty-session-on-both-hosts.md | 50 +++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 .github/ISSUE_SPECS/TBD29-refuse-a-source-with-more-than-two-channels.md create mode 100644 .github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md diff --git a/.github/ISSUE_SPECS/TBD29-refuse-a-source-with-more-than-two-channels.md b/.github/ISSUE_SPECS/TBD29-refuse-a-source-with-more-than-two-channels.md new file mode 100644 index 000000000..507447985 --- /dev/null +++ b/.github/ISSUE_SPECS/TBD29-refuse-a-source-with-more-than-two-channels.md @@ -0,0 +1,54 @@ +# Refuse a source with more than two channels + +Owner answer (2026-10-15): sources are mono or stereo only. Decision 17 programme. Code anchors verified on `4be10cd67` (PR #1680). Issue number: assigned at filing (written here as TBD29). Feature sprint mode (owner, 2026-10-13): one implementer, normal tests, a batch review. + +## Product outcome + +A session whose source declares more than 2 channels is refused at session load with a typed +error, the same on both hosts, and the browser's source staging is sized for 2 channels (about +1 KB instead of the 130 KB the grammar's 255-channel domain needs). + +## Context + +- `session::SOURCE_CHANNELS_MAXIMUM` is `u8::MAX` (`crates/session/src/model.rs:217`), the + domain of `Source::channels`; nothing narrower is validated. +- #1562b's review fold (B1) sized the browser's source PCM and plane staging by that maximum + (`hosts/host-web/src/lib.rs`, `project_buffers`), so a rebuild can add any valid source. +- Product rule (dual mono by source file): only mono source files get dual-mono processing; stereo + files stay stereo. No source has more than 2 channels. + +## Decisions frozen for this slice + +- **D1.** One rule in the session validator: `channels` is 1 or 2; any other value is refused with a + typed code (for example `session.source.channels`), at load and in a structural edit's + validation, so both hosts refuse it the same way. +- **D2.** A launch constant (for example `SOURCE_CHANNELS_LAUNCH_MAXIMUM = 2`) in the session crate + is the one source of the limit; the browser staging, the C ABI's source checks and the SDK + builder read it (the SDK through its generated constants). +- **D3.** The browser's staging is sized by D2. + +## Objective gates + +1. A session with a 3-channel source is refused at load on both hosts with the typed code; a + structural edit that adds one is refused before commit; a parity row. +2. Mono and stereo sources load, feed and render as today (existing tests green). +3. Browser `sourcePcmStagingBytes` falls back to the 2-channel size; `expected.json` and + `retained_ceilings.rs` re-pinned from measurements; the boot budget passes. +4. `cargo test --locked -p session -p session-validator -p capi -p host-web --features + host-web/test-support`; SDK checks; the author-session skill and `SESSION_SCHEMA_V1.md` state + the limit. + +## Authorized paths + +- `crates/session/src/{model.rs,validate.rs,diagnostic.rs}`, `crates/session/tests/`, + `hosts/host-web/src/lib.rs` (the staging size), host-web tests and `expected.json` rows, + `crates/capi` tests, `sdk/` generated constants and evals, `docs/SESSION_SCHEMA_V1.md`, + `.claude/skills/author-session/SKILL.md`, this spec's record. + +## Dependencies + +- #1562 (B1 staging). + +## Size + +Half a day. diff --git a/.github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md b/.github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md new file mode 100644 index 000000000..31924643d --- /dev/null +++ b/.github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md @@ -0,0 +1,50 @@ +# Boot an empty session on both hosts + +Owner answer (2026-10-15): an app opens an empty mixer and adds stems through structural edits. +Decision 17 programme. Code anchors verified on `4be10cd67` (PR #1680). Issue number: assigned at filing (written here as TBD30). Feature sprint mode (owner, 2026-10-13): one implementer, normal tests, a batch review. + +## Product outcome + +A session with no source and no track boots on both hosts (it renders silence at the output), and +a structural edit can then add a stereo stem with its track; both hosts render it identically. + +## Context + +- A session with no source or no track fails to boot today: host-core's source graph preparation + refuses it (`graph_failure("host.source.graph.prepare")`, `crates/host-core/src/prepare.rs:2570`), + at least on the browser; #1562b's gates had to boot with one mono source. +- Since #1562b both hosts accept rebuilds through the one core path, so the first stem can be added + by a structural transaction. + +## Decisions frozen for this slice + +- **D1.** An empty session (no source, no track; submixes, VCAs and console slots may be declared) + is valid and prepares a plan that renders silence at the output, on both hosts, with no special + path in a host adapter. +- **D2.** Every boot-time size that depends on the session (strip counts, meter frames, staging) + handles zero, and grows by the existing successor paths when the first strip arrives. + +## Objective gates + +1. Boot empty on both hosts: the plan renders silence, status, meters (an empty frame) and the + control list are consistent; zero render allocations. +2. **Parity row:** boot empty, add a stereo stem and a track by one structural transaction, feed the + stem and hear it, bit-identical on the browser and the C ABI at the four launch rates. +3. The browser qualification gains an empty-boot leg (chromium, firefox, webkit). +4. `cargo test --locked -p host-core --all-features -p control-plane --features test-support -p + capi -p host-web --features host-web/test-support`; boot budget. + +## Authorized paths + +- `crates/host-core/src/prepare.rs` (and `shape.rs`), `crates/graph-compiler` if an empty graph + needs it, `crates/control-plane`, `crates/capi` tests, `hosts/host-web/**` (empty-boot sizes), + the worklet JS and SDK only where an empty shape is refused today, `hosts/host-web/qualification`, + this spec's record. + +## Dependencies + +- #1562 (rebuilds on the browser). + +## Size + +Half a day to a day. From b74fe19036fb2ffe8afe9a7d5bf0b2e4920d8ecc Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 13:01:56 +0000 Subject: [PATCH 02/12] Keep a one-shot spectrum window out of the continuous stream (#1616) The continuous read discards any popped record whose stream epoch is not the current one, inside its bounded pop loop, so a one-shot record that lands after a mode switch's drain is never returned as a continuous window; the #1609 single-record invalidation flag it supersedes is deleted. A loom case explores the race. Co-Authored-By: Claude Opus 5.5 --- ...rum-window-out-of-the-continuous-stream.md | 44 +++++ crates/host-core/src/spectrum.rs | 159 ++++++++++++++++-- 2 files changed, 186 insertions(+), 17 deletions(-) diff --git a/.github/ISSUE_SPECS/1616-keep-a-one-shot-spectrum-window-out-of-the-continuous-stream.md b/.github/ISSUE_SPECS/1616-keep-a-one-shot-spectrum-window-out-of-the-continuous-stream.md index 6cad829af..70aa8a7c8 100644 --- a/.github/ISSUE_SPECS/1616-keep-a-one-shot-spectrum-window-out-of-the-continuous-stream.md +++ b/.github/ISSUE_SPECS/1616-keep-a-one-shot-spectrum-window-out-of-the-continuous-stream.md @@ -56,3 +56,47 @@ continuous window. ## Size Half a day, one sonnet-high implementer. + +## Decision record: one-shot window out of the continuous stream (batch 8) + +**Decision: option A.** `try_read_continuous_record` (`crates/host-core/src/spectrum.rs`) loads +the stream epoch once before its bounded pop loop and discards every popped record whose +`stream_epoch` is not that epoch. Render is unchanged on the publish side. The pop loop stays +inside the existing `REALTIME_POLICY_BEGIN/END` region and keeps its bound (`available`, frozen at +entry and capped at `SPECTRUM_RESULT_SLOTS`); a discard spends one pop of it, so a read that +meets only stale records answers `Warming` or `Pending`, never a refill chase. + +**What the check covers.** A one-shot record (epoch 0, below every continuous epoch) that render +pushes after the switch's drain and before its mode store; a record of an epoch a restart +(`restart_continuous`, control) ended while render was publishing; and a record of an epoch a +render failure ended. Only control changes the epoch outside a failure, and a failure moves it +forward before render publishes anything of the new epoch, so one load covers the loop. + +**Departure from the text (code is newer than the spec).** #1609 left an invalidation rule in the +same loop: `invalidated`/`invalidated_epoch` discarded one record with an epoch at or below the +failed one. Every failure bumps the epoch, so the new epoch check subsumes that rule and discards +all such records, not one. Keeping both left a dead flag, so the two fields, their stores in +`continuous_fail` and `commit_continuous`, and the guard arm are removed. Render does two fewer +atomic stores on a failure; nothing else on the render side moved. + +**Gates.** +1. `a_one_shot_record_that_lands_after_the_mode_switch_is_never_a_continuous_window` arms a + capture, completes a one-shot window, switches through the collection's replacement path + (`begin_continuous(.., true)`), pushes the epoch-0 record as render's block end would after the + drain, and shows the read answers `Warming` and the next window is epoch 1, sequence 0. + `a_record_of_an_older_epoch_is_discarded_by_the_continuous_read` does the same for an epoch-1 + record after a restart to epoch 2. Both are red with the epoch arm disabled (as are five + failure tests that formerly relied on the invalidation rule). +2. The loom model gains `spectrum_mode_switch_loom` (same module, so the CI step filter + `spectrum_handoff_loom` runs it): render completes and publishes an armed one-shot window while + control replaces the capture with a continuous stream; every interleaving of the push against + the drain and the mode store must return only windows of epoch 1 (a continuous window of the + same samples is a correct result when render already saw the new mode). `mode` is a plain + `std` atomic, but render loads it before its first modelled operation and control stores it + after its last, so loom still places the push before, between and after the drain; the model is + red with the epoch arm disabled. +3. `cargo test --locked -p host-core --all-features`, the loom step, `host-web` with + `test-support`, `realtime-policy`, `check-bench-policy.sh`, clippy `-D warnings` (no features, + all features) and rustdoc `-D warnings` are green. + +No measured numbers: no benchmark was run (feature sprint mode). diff --git a/crates/host-core/src/spectrum.rs b/crates/host-core/src/spectrum.rs index 6dec96183..e4d52bb44 100644 --- a/crates/host-core/src/spectrum.rs +++ b/crates/host-core/src/spectrum.rs @@ -816,7 +816,6 @@ impl SpectrumCapture { self.shared.failure_epoch.store(epoch, Ordering::Release); self.shared.drops.store(0, Ordering::Release); self.shared.drop_epoch.store(epoch, Ordering::Release); - self.shared.invalidated.store(0, Ordering::Release); self.seen_failures = 0; self.seen_drops = 0; self.recovery_pending = false; @@ -920,15 +919,17 @@ impl SpectrumCapture { // entry. A later producer publication belongs to a later read and cannot extend this // budget. self.recovery_pending = false; + // Only control changes the epoch (`commit_continuous`; render's failure path moves it + // forward before it publishes anything of the new epoch), so one load covers the loop. + let epoch = self.shared.epoch.load(Ordering::Acquire); for _ in 0..available { match self.consumer.try_pop() { - Ok(record) - if self.shared.invalidated.load(Ordering::Acquire) != 0 - && record.stream_epoch - <= self.shared.invalidated_epoch.load(Ordering::Acquire) => - { - self.shared.invalidated.store(0, Ordering::Release); - } + // A record of another epoch is never a window of this stream: a one-shot window + // (epoch 0, below every continuous epoch) that render completed while control + // switched modes, a window of an epoch a failure or a restart ended, or a + // window published against an epoch read before a restart. Discarding it spends + // one pop of the bound frozen above, so it never delays a read past the next one. + Ok(record) if record.stream_epoch != epoch => {} Ok(record) => { return Ok(record); } @@ -1598,8 +1599,6 @@ struct SpectrumContinuousShared { sample_rate_hz: AtomicU64, quantum_frames: AtomicU64, hop_frames: AtomicU64, - invalidated: AtomicU8, - invalidated_epoch: AtomicU64, } impl SpectrumContinuousShared { @@ -1615,8 +1614,6 @@ impl SpectrumContinuousShared { sample_rate_hz: AtomicU64::new(0), quantum_frames: AtomicU64::new(0), hop_frames: AtomicU64::new(0), - invalidated: AtomicU8::new(0), - invalidated_epoch: AtomicU64::new(0), }) } } @@ -2592,11 +2589,6 @@ fn continuous_fail(state: &mut SpectrumContinuousState) { return; } let current_epoch = state.shared.epoch.load(Ordering::Acquire); - state - .shared - .invalidated_epoch - .store(current_epoch, Ordering::Release); - state.shared.invalidated.store(1, Ordering::Release); let Some(next_epoch) = current_epoch.checked_add(1) else { state.shared.active.store(0, Ordering::Release); return; @@ -3766,6 +3758,86 @@ mod tests { assert_eq!(capture.consumer.available_at_entry(), 0); } + /// A record of the one-shot epoch (0) left in the queue by a mode switch. + fn stale_record(stream_epoch: u64, arm_generation: u64) -> super::SpectrumCapturedRecord { + super::SpectrumCapturedRecord { + window: window(0.75), + stream_epoch, + sequence: 7, + dropped_captures: 0, + arm_generation, + } + } + + /// Issue #1616: a one-shot window that render pushes after the switch's drain and before its + /// mode store reaches the queue of a continuous stream. The queue holds exactly that record + /// after the switch returns (the observer owns the producer, so the test pushes it as render + /// would). The continuous read must discard it and answer `Warming`, then return the first + /// window of the new epoch. + #[test] + fn a_one_shot_record_that_lands_after_the_mode_switch_is_never_a_continuous_window() { + let (mut observer, mut capture) = continuous_pair(SpectrumChannels::Left); + capture.arm().expect("one-shot arm"); + let (left, right) = ramp_block(0, SPECTRUM_WINDOW_FRAMES); + observer.capture(&left, &right, 0, super::GraphObservationValidity::CLEAR); + assert!( + observer.one_shot.held, + "the one-shot window is complete and held" + ); + let generation = super::word_generation(observer.one_shot.latched); + // The collection's replacement path switches an armed capture: drain, park, mode store. + let cadence = SpectrumCadence::new(48_000, 128).expect("cadence"); + capture + .begin_continuous(cadence, true) + .expect("replace the armed one-shot capture"); + assert!( + observer + .producer + .try_push(stale_record(0, generation)) + .is_ok(), + "render's block end publishes the one-shot window after the drain" + ); + assert_eq!( + capture + .try_read_continuous() + .expect_err("a one-shot window is not a continuous window"), + SpectrumContinuousReadError::Warming + ); + assert_eq!(capture.consumer.available_at_entry(), 0); + // The stream proceeds: its first window carries the new epoch. + feed_blocks(&mut observer, 0, SPECTRUM_WINDOW_FRAMES); + let window = capture + .try_read_continuous() + .expect("the first window of the new epoch"); + assert_eq!(window.stream_epoch, 1); + assert_eq!(window.sequence, 0); + } + + /// A record an earlier epoch left behind (a restart does not stop render) is discarded the + /// same way, and it neither fails nor delays the read of the current epoch's window. + #[test] + fn a_record_of_an_older_epoch_is_discarded_by_the_continuous_read() { + let (mut observer, mut capture) = continuous_pair(SpectrumChannels::Left); + capture.start_continuous(48_000, 128).expect("start"); + capture.restart_continuous().expect("restart"); + assert_eq!(capture.stream_epoch(), Some(2)); + assert!(observer.producer.try_push(stale_record(1, 0)).is_ok()); + assert_eq!( + capture + .try_read_continuous() + .expect_err("an epoch 1 window is not an epoch 2 window"), + SpectrumContinuousReadError::Warming + ); + feed_blocks(&mut observer, 0, SPECTRUM_WINDOW_FRAMES); + assert_eq!( + capture + .try_read_continuous() + .expect("the epoch 2 window") + .stream_epoch, + 2 + ); + } + /// A capture around a `slots`-slot queue whose producer the test holds, built as /// `continuous_pair` builds its pair, and a record to push into it. fn capture_with_held_producer( @@ -5892,6 +5964,59 @@ mod tests { loom::model(|| spawn_with_stack(handoff).join().expect("handoff driver")); } + /// Issue #1616: a mode switch racing a one-shot completion. + /// + /// Render completes and publishes an armed one-shot window while control replaces the + /// armed capture with a continuous stream (drain, park, epoch, mode store). The `mode` + /// word is a plain `std` atomic, but render reads it before its first modelled operation + /// and control stores it after its last, so loom still explores every position of the + /// push relative to the drain: before it, between it and the mode store, and after both. + /// The continuous read must never return the one-shot window, in any of them. + #[test] + fn spectrum_mode_switch_loom() { + loom::model(|| { + spawn_with_stack(mode_switch) + .join() + .expect("mode switch driver") + }); + } + + fn mode_switch() { + let (mut observer, mut capture) = continuous_pair(SpectrumChannels::Left); + capture.arm().expect("one-shot arm"); + let render = spawn_with_stack(move || { + let left = [0.25_f32; SPECTRUM_WINDOW_FRAMES]; + let right = [-0.5_f32; SPECTRUM_WINDOW_FRAMES]; + observer.capture( + &left, + &right, + 0, + crate::spectrum::GraphObservationValidity::CLEAR, + ); + observer.block_end(); + observer + }); + let cadence = crate::spectrum::SpectrumCadence::new(48_000, 128).expect("cadence"); + capture + .begin_continuous(cadence, true) + .expect("the replacement is never Busy"); + // Render may also have seen the new mode and published a continuous window of the + // same samples, which is a correct result; only another epoch is not. + let read = |capture: &mut crate::spectrum::SpectrumCapture| { + if let Ok(window) = capture.try_read_continuous() { + assert_eq!( + window.stream_epoch, 1, + "a continuous read returned a window of another epoch" + ); + } + }; + read(&mut capture); + let _observer = render.join().expect("render thread"); + // The one-shot window may now sit in the queue: it is discarded, never returned. + read(&mut capture); + read(&mut capture); + } + fn handoff() { { let (mut observer, mut capture) = continuous_pair(SpectrumChannels::Left); From 0d8e0e56eb7a934e4ae2a7dd2640bd68c3726c1b Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 13:09:50 +0000 Subject: [PATCH 03/12] Refuse a source with more than two channels on both hosts (TBD29) Owner answer 2026-10-15: sources are mono or stereo. One session-validator rule refuses 0 or more than 2 channels with source.channels.unsupported, at load and in a structural edit; session::SOURCE_CHANNELS_LAUNCH_MAXIMUM = 2 is the one limit the browser staging, the C ABI and the SDK (generated ABI constant) read. Browser source staging back to 1,024 bytes; retained ceilings re-pinned from measurements. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/author-session/SKILL.md | 5 +- ...se-a-source-with-more-than-two-channels.md | 62 +++++++++++ crates/capi/src/ffi.rs | 37 +++++-- crates/host-core/src/lib.rs | 3 + .../host-core/src/limiter_linked_session.rs | 62 ++++++----- crates/session/src/diagnostic.rs | 4 + crates/session/src/model.rs | 11 +- crates/session/src/validate.rs | 10 +- crates/session/tests/diagnostic_parity.rs | 10 +- crates/session/tests/invalid_matrix.rs | 12 ++- docs/SESSION_SCHEMA_V1.md | 5 +- hosts/host-web/src/ffi.rs | 2 +- hosts/host-web/src/lib.rs | 6 +- hosts/host-web/src/tests.rs | 16 +-- hosts/host-web/tests/browser-v1/expected.json | 10 +- hosts/host-web/tests/control_parity.rs | 102 +++++++++++++++++- hosts/host-web/tests/plan_exchange_threads.rs | 27 +++-- hosts/host-web/tests/retained_ceilings.rs | 12 ++- scripts/check-abi-layout-v1.py | 3 +- scripts/fixtures/abi-layout-v1-self-test.json | 1 + sdk/assets/miso-engine-v1-abi-layout.json | 1 + sdk/src/core/session.ts | 7 +- sdk/src/generated/abi.ts | 1 + sdk/test/builder-evals.mjs | 20 ++++ tools/parameter-metadata/src/abi_layout.rs | 5 + 25 files changed, 345 insertions(+), 89 deletions(-) diff --git a/.claude/skills/author-session/SKILL.md b/.claude/skills/author-session/SKILL.md index b64ab9a84..0bc3fe11d 100644 --- a/.claude/skills/author-session/SKILL.md +++ b/.claude/skills/author-session/SKILL.md @@ -63,8 +63,9 @@ target it. ## Core shapes and vocabularies - A source has exactly `id`, `content`, `channels`, `bit_depth`, and `frames`. `content` is - `blake3:` plus 64 lowercase hex digits; `frames` is a nonzero decimal string; `bit_depth` is - `16`, `24`, or `"32f"`. + `blake3:` plus 64 lowercase hex digits; `channels` is `1` or `2` (a source is mono or stereo; any + other count is refused with `source.channels.unsupported`); `frames` is a nonzero decimal string; + `bit_depth` is `16`, `24`, or `"32f"`. - An output is `{"id":"main-out"}`. - A track is `id`, `source_id`, `left_source_channel`, `right_source_channel`, `builtins`, `console`, `inserts`, `fader`, then either `pan` with `left`, `right`, `smoothing_samples`, or diff --git a/.github/ISSUE_SPECS/TBD29-refuse-a-source-with-more-than-two-channels.md b/.github/ISSUE_SPECS/TBD29-refuse-a-source-with-more-than-two-channels.md index 507447985..53cb5fcce 100644 --- a/.github/ISSUE_SPECS/TBD29-refuse-a-source-with-more-than-two-channels.md +++ b/.github/ISSUE_SPECS/TBD29-refuse-a-source-with-more-than-two-channels.md @@ -52,3 +52,65 @@ error, the same on both hosts, and the browser's source staging is sized for 2 c ## Size Half a day. + +## Decision record: refuse a source with more than two channels (batch 8) + +**Shipped.** + +- D1. One rule in `session::validate`: `Source::channels` is 1 or 2. Zero and more than two are one + rule with one new typed code, `DiagnosticCode::SourceChannelsUnsupported` + (`source.channels.unsupported`, path `$.sources[i].channels`). It replaces the old + `capacity.zero` for `channels == 0` (frames keep `capacity.zero`). Every load and every + structural edit already validates the candidate model through this function, so both hosts + refuse alike. +- D2. `session::SOURCE_CHANNELS_LAUNCH_MAXIMUM = 2` replaces `SOURCE_CHANNELS_MAXIMUM` (255, + deleted: nothing else read it). Readers: the validator, the browser's staging + (`hosts/host-web/src/lib.rs` `project_buffers`), the C ABI's source submission + (`crates/capi/src/ffi.rs`: the plane-count bound and the stack plane array, 255 to 2, through + `host_core::SOURCE_CHANNELS_LAUNCH_MAXIMUM`, a re-export beside `LAUNCH_SAMPLE_RATES`) and the + SDK (new ABI-layout constant `maximumSourceChannels`, written by `parameter-metadata`, read by + the session builder). +- D3. The browser's source PCM and plane staging are sized by the constant: `sourcePcmStagingBytes` + 130,560 to 1,024. The ID staging keeps its 127 bytes (#1562b B1). + +**Departure.** `HostPrepareCaps::maximum_source_channels` stays `None` everywhere: the validator is +the one rule, a second per-host cap would be a second place to drift. + +**C ABI.** A chunk with three or more planes is now `RESULT_INVALID_ARGUMENT` at the boundary +(it was `source.channels.mismatch` from the facade); the row "three planes for a stereo source" in +`source_rejections_reach_the_c_host_as_their_own_diagnostic` became "one plane for a stereo source" +and a separate assertion covers three planes. + +**Fixtures and corpora.** No fixture under `fixtures/` declares a source of more than two channels. +Tests that did: `host-web` `three_source_session` (alpha, 4 channels, now 1) and +`plan_exchange_threads` (a six-channel source, now a stereo one; renamed +`a_rebuild_adds_the_longest_id_and_feeds_the_new_source`; #1562's record still names the old test +and is history). `hosts/host-web/tests/stem-pump-v1.mjs` pumps a 3-channel PCM file through the +stem pump: it is the pump's own channel-generic test and builds no session, so it stays. + +**Gates.** + +1. `control_parity::both_hosts_refuse_a_source_of_more_than_two_channels_alike`: a three-channel + source is refused at load by the C ABI compile and the browser boot with identical diagnostics + containing `source.channels.unsupported`; mono and stereo load; a structural `UpsertSource` of + three channels is refused on both with identical reply bytes, the code + `source.channels.unsupported` and the revision unmoved (four rates). Session crate: + `diagnostic_parity` (`source-channels-zero`, `source-channels-three`) and `invalid_matrix` + (`source_identity_and_shape_category_has_22_distinct_cases`). +2. Mono and stereo unchanged: the existing host-web, capi, control-plane and conformance suites. +3. Browser `expected.json`: `sourcePcmStagingBytes` 1,024, `bridgeRetainedBytes` ceiling 1,812,352 + to 1,667,648 (measured 1,611,330 on the simd128 module; 562,754 beyond the capture, plus 10 %, + rounded up to 64), the three `memoryBytes` 3,014,656 to 2,883,584. `retained_ceilings.rs` + `bridge_retained_bytes` 787,968 to 641,024 (measured 1,631,294 x86-64, 582,718 beyond the + capture). Boot high-water growth (`check-web-boot-budget.mjs`): 20,447,232 bytes (before: + 20,447,232). +4. SDK: `builder-evals` "a source of other than one or two channels is refused at its own path"; + `sdk/src/generated/abi.ts` and `sdk/assets/miso-engine-v1-abi-layout.json` regenerated; + `scripts/check-abi-layout-v1.py` and its self-test fixture know `maximumSourceChannels`. + `docs/SESSION_SCHEMA_V1.md` and the author-session skill state the limit. + +**Found in review of callers.** `crates/host-core/src/limiter_linked_session.rs` (a `#[cfg(test)]` +unit test, the 64-track linked-limiter corpus) widened its one source to 128 channels. It now +declares 64 stereo sources (`stream-NN`), track `k` reads source `k`'s two channels and the same +noise streams `2k` and `2k + 1` feed them; `maximum_sources` 16 to 64. The `PIN` digest is +unchanged and the three width tests pass, so no rendered bit moved. diff --git a/crates/capi/src/ffi.rs b/crates/capi/src/ffi.rs index af5dbad67..c6ae5c5bd 100644 --- a/crates/capi/src/ffi.rs +++ b/crates/capi/src/ffi.rs @@ -450,7 +450,7 @@ pub unsafe extern "C" fn miso_engine_v1_source_submit_planar_f32( || report.struct_size != crate::SUBMIT_REPORT_SIZE || report.reserved0 != 0 || chunk.plane_count == 0 - || chunk.plane_count > 255 + || chunk.plane_count > host_core::SOURCE_CHANNELS_LAUNCH_MAXIMUM || chunk.planes.is_null() || !chunk.planes.is_aligned() || u64::from(chunk.frames).saturating_mul(core::mem::size_of::() as u64) @@ -466,11 +466,12 @@ pub unsafe extern "C" fn miso_engine_v1_source_submit_planar_f32( }; let plane_count = chunk.plane_count as usize; // SAFETY: `chunk.planes` was proved nonnull and pointer-aligned above, `plane_count` is at - // most 255 so the array is far inside `isize::MAX` bytes, and the caller promises that many - // readable plane pointers for this call. + // most the launch source-channel maximum (two) so the array is far inside `isize::MAX` + // bytes, and the caller promises that many readable plane pointers for this call. let plane_pointers = unsafe { core::slice::from_raw_parts(chunk.planes, plane_count) }; let frames = chunk.frames as usize; - let mut planes: [&[f32]; 255] = [&[]; 255]; + let mut planes: [&[f32]; host_core::SOURCE_CHANNELS_LAUNCH_MAXIMUM as usize] = + [&[]; host_core::SOURCE_CHANNELS_LAUNCH_MAXIMUM as usize]; for (index, plane) in plane_pointers.iter().enumerate() { if plane.is_null() || !plane.is_aligned() { return RESULT_INVALID_ARGUMENT; @@ -2318,12 +2319,9 @@ mod tests { b"source.rate.mismatch", ), ( - "three planes for a stereo source", + "one plane for a stereo source", b"fixture-source", - &|chunk| { - chunk.planes = three.as_ptr(); - chunk.plane_count = 3; - }, + &|chunk| chunk.plane_count = 1, b"source.channels.mismatch", ), ( @@ -2366,6 +2364,27 @@ mod tests { assert_eq!(read_last_error(session.cast()), diagnostic, "{why}"); } + // A source is mono or stereo (owner answer of 2026-10-15): a third plane is not a + // channel-count mismatch against some declaration but an argument no source can match, so + // it is refused at the boundary before any plane is read. + let mut chunk = base(); + chunk.planes = three.as_ptr(); + chunk.plane_count = 3; + assert_eq!( + // SAFETY: As above; the chunk is refused before any plane is read. + unsafe { + miso_engine_v1_source_submit_planar_f32( + session, + b"fixture-source".as_ptr(), + 14, + &chunk, + &mut report, + ) + }, + RESULT_INVALID_ARGUMENT, + "three planes" + ); + // The seek path reports through the same table. assert_eq!( seek(session, b"fixture-source".as_ptr(), 14, 1, 48_001), diff --git a/crates/host-core/src/lib.rs b/crates/host-core/src/lib.rs index 67b2e3d80..19469e3e1 100644 --- a/crates/host-core/src/lib.rs +++ b/crates/host-core/src/lib.rs @@ -177,6 +177,9 @@ pub use route_controls::{ RouteControlError, RouteControlProducer, RouteControlRecord, RouteControlResources, route_producer_position, route_record, }; +/// The most channels one source can declare at launch (mono or stereo): the one limit the session +/// validator, the C ABI's source submission and the browser's staging all read. +pub use session::SOURCE_CHANNELS_LAUNCH_MAXIMUM; pub use shape::{HostSessionShape, compiled_session_shape}; pub use source::{ SourceControlError, SourceControlSet, SourceSubmission, control_table_bytes, diff --git a/crates/host-core/src/limiter_linked_session.rs b/crates/host-core/src/limiter_linked_session.rs index aa02ca67c..5bca5f62d 100644 --- a/crates/host-core/src/limiter_linked_session.rs +++ b/crates/host-core/src/limiter_linked_session.rs @@ -35,8 +35,9 @@ //! bank there is refused rather than rendered per node (#1098; see `folded`). Two edits, each made //! for a measured reason: //! -//! * **One stereo stream per track.** The fixture's one source is widened to 128 channels and -//! track `k` reads channels `2k` and `2k + 1`. With the fixture's one stereo source every lane +//! * **One stereo stream per track.** The fixture's one source becomes sixty-four stereo sources +//! (a source is mono or stereo, so the streams cannot share one wide source) and track `k` reads +//! source `k`'s two channels, fed noise streams `2k` and `2k + 1`. With the fixture's one source every lane //! of a bank hears the same noise, so an unlink is one trial rather than eight. On that shape, //! with the compressors bypassed and three unlinks, M1 stayed green. //! * **Every compressor bypassed** from block 0, through its live control channel (the rack's @@ -166,12 +167,15 @@ use crate::{ const FIXTURE: &str = include_str!("../../../fixtures/session/v1/console-sixty-four-track-intended.json"); -const SOURCE: &[u8] = b"fixture-source"; const LIMITER: &str = "miso.true-peak-limiter"; const COMPRESSOR: &str = "miso.compressor"; const TRACKS: usize = 64; -/// Source channels: one independent stereo stream per track. +/// The noise streams: one independent stereo pair per track, each fed to that track's own source. const CHANNELS: usize = 2 * TRACKS; +/// The ID of the source track `track` reads. +fn source_id(track: usize) -> String { + format!("stream-{track:02}") +} const QUANTUM: usize = 128; const BLOCKS: usize = 96; /// Frames per noise segment. Divides neither the quantum nor the limiter's 241-frame window. @@ -260,7 +264,7 @@ fn caps() -> HostPrepareCaps { maximum_submixes: 256, maximum_vcas: 256, maximum_vca_reach_pairs: u64::MAX, - maximum_sources: 16, + maximum_sources: 64, maximum_routes: 256, maximum_effects: 256, maximum_graph_session_plus_plan_bytes: 1_000_000_000, @@ -312,10 +316,18 @@ fn strip_address(backend: Backend, index: u32) -> LiveEffectAddress { fn session(backend: Backend) -> String { let mut model = parse_session_json(FIXTURE).expect("the fixture parses"); assert_eq!(model.sources.len(), 1, "the fixture has one source"); - model.sources[0].channels = u8::try_from(CHANNELS).expect("channel count"); + let template = model.sources[0].clone(); + model.sources = (0..TRACKS) + .map(|index| { + let mut source = template.clone(); + source.id = session::StableId::parse(&source_id(index)).expect("source ID"); + source + }) + .collect(); for (index, track) in model.tracks.iter_mut().enumerate() { - track.left_source_channel = u8::try_from(2 * index).expect("left channel"); - track.right_source_channel = u8::try_from(2 * index + 1).expect("right channel"); + track.source_id = session::StableId::parse(&source_id(index)).expect("source ID"); + track.left_source_channel = 0; + track.right_source_channel = 1; } if folded(backend) { let chains: Vec> = model @@ -575,22 +587,24 @@ fn run(backend: Backend) -> Run { *sample = source_sample(channel as u64, first + frame as u64); } } - let views: Vec<&[f32]> = planes.iter().map(|plane| &plane[..]).collect(); - console - .prepared - .sources - .submit( - SOURCE, - SourceSubmission { - generation: 1, - start_frame: first, - sample_rate_hz: 48_000, - planes: &views, - frames: QUANTUM as u32, - end_of_region: false, - }, - ) - .expect("source block"); + for track in 0..TRACKS { + let pair = [&planes[2 * track][..], &planes[2 * track + 1][..]]; + console + .prepared + .sources + .submit( + source_id(track).as_bytes(), + SourceSubmission { + generation: 1, + start_frame: first, + sample_rate_hz: 48_000, + planes: &pair, + frames: QUANTUM as u32, + end_of_region: false, + }, + ) + .expect("source block"); + } let mut samples = [0.0_f32; QUANTUM * 2]; let output = PlanarBufferMut::try_new(&mut samples, 2, QUANTUM, QUANTUM).expect("output planes"); diff --git a/crates/session/src/diagnostic.rs b/crates/session/src/diagnostic.rs index d9016a321..3b8b2ea35 100644 --- a/crates/session/src/diagnostic.rs +++ b/crates/session/src/diagnostic.rs @@ -41,6 +41,9 @@ pub enum DiagnosticCode { SourceContentIdentityFormat, /// A source bit-depth declaration is outside the closed token set. SourceBitDepthUnsupported, + /// A source declares zero channels or more than the launch maximum of two (a source is mono + /// or stereo). + SourceChannelsUnsupported, /// A numeric token is NaN or infinity. NumericNonFinite, /// An engine session rate is outside the launch-supported tier. @@ -98,6 +101,7 @@ impl DiagnosticCode { Self::InvalidEnum => "schema.invalid_enum", Self::SourceContentIdentityFormat => "source.content.identity_format", Self::SourceBitDepthUnsupported => "source.bit_depth.unsupported", + Self::SourceChannelsUnsupported => "source.channels.unsupported", Self::NumericNonFinite => "numeric.non_finite", Self::SampleRateUnsupportedAtLaunch => "sample_rate.unsupported_at_launch", Self::NumericNotF32Representable => "numeric.not_f32_representable", diff --git a/crates/session/src/model.rs b/crates/session/src/model.rs index d312258d0..4169e0420 100644 --- a/crates/session/src/model.rs +++ b/crates/session/src/model.rs @@ -211,10 +211,13 @@ closed_tokens! { } } -/// The most channels one source can declare: `Source::channels` is a `u8`, and the grammar has no -/// narrower launch limit. A host sizes any staging that must hold a source of a session it has not -/// yet seen (a rebuild can add one) by this. -pub const SOURCE_CHANNELS_MAXIMUM: u32 = u8::MAX as u32; +/// The most channels one source can declare at launch: sources are mono or stereo (owner answer of +/// 2026-10-15), so `Source::channels` is 1 or 2 and the session validator refuses anything else +/// with `source.channels.unsupported`. This is the one source of the limit: a host sizes any +/// staging that must hold a source of a session it has not yet seen (a rebuild can add one) by +/// it, the C ABI's and browser's source checks read it, and the SDK reads it from its generated +/// ABI constants (`maximumSourceChannels`). +pub const SOURCE_CHANNELS_LAUNCH_MAXIMUM: u32 = 2; /// Explicit PCM output profile. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/session/src/validate.rs b/crates/session/src/validate.rs index 205098c78..836e7d4ba 100644 --- a/crates/session/src/validate.rs +++ b/crates/session/src/validate.rs @@ -3,8 +3,8 @@ use crate::{ Automation, AutomationSegment, AutomationShape, ConsoleEntry, ControlAddress, Diagnostic, DiagnosticCode, DiagnosticSet, DualMonoBuiltins, DualMonoFader, Effect, MatrixOrPan, Owner, ParameterChannel, ParameterUnit, ROUTE_COEFFICIENT_MAGNITUDE_MAXIMUM, ROUTE_GAIN_DB_MAXIMUM, - ROUTE_GAIN_DB_MINIMUM, Rack, RouteDestination, RouteSource, SESSION_SCHEMA_VERSION_V1, Section, - SessionModel, Source, StableId, Submix, Track, + ROUTE_GAIN_DB_MINIMUM, Rack, RouteDestination, RouteSource, SESSION_SCHEMA_VERSION_V1, + SOURCE_CHANNELS_LAUNCH_MAXIMUM, Section, SessionModel, Source, StableId, Submix, Track, diagnostic::{MAXIMUM_SESSION_DIAGNOSTICS, PathRef}, }; use engine::{SampleRateHz, is_launch_sample_rate}; @@ -216,12 +216,12 @@ fn validate_sources(session: &SessionModel, root: &PathRef<'_>, diagnostics: &mu "source content must match blake3:[0-9a-f]{64}", ); } - if source.channels == 0 { + if source.channels == 0 || u32::from(source.channels) > SOURCE_CHANNELS_LAUNCH_MAXIMUM { error( diagnostics, - DiagnosticCode::CapacityZero, + DiagnosticCode::SourceChannelsUnsupported, &path.key("channels"), - "source channels must be nonzero", + "a source is mono or stereo: channels must be 1 or 2", ); } if source.frames == 0 { diff --git a/crates/session/tests/diagnostic_parity.rs b/crates/session/tests/diagnostic_parity.rs index b14a1e8dc..b686036ff 100644 --- a/crates/session/tests/diagnostic_parity.rs +++ b/crates/session/tests/diagnostic_parity.rs @@ -367,11 +367,17 @@ fn parse_canonical_and_compile_diagnostics_have_code_path_and_span_parity() { |s| s.sources[0].content.replace_range(7..8, "g") ), case!( - "source-channels", - CapacityZero, + "source-channels-zero", + SourceChannelsUnsupported, "$.sources[0].channels", |s| s.sources[0].channels = 0 ), + case!( + "source-channels-three", + SourceChannelsUnsupported, + "$.sources[0].channels", + |s| s.sources[0].channels = 3 + ), case!("source-frames", CapacityZero, "$.sources[0].frames", |s| { s.sources[0].frames = 0 }), diff --git a/crates/session/tests/invalid_matrix.rs b/crates/session/tests/invalid_matrix.rs index 1e72e1c21..3640bce79 100644 --- a/crates/session/tests/invalid_matrix.rs +++ b/crates/session/tests/invalid_matrix.rs @@ -411,7 +411,7 @@ fn stable_id_category_has_20_distinct_cases() { } #[test] -fn source_identity_and_shape_category_has_21_distinct_cases() { +fn source_identity_and_shape_category_has_22_distinct_cases() { let mut count = 0; model_case( &mut count, @@ -456,7 +456,13 @@ fn source_identity_and_shape_category_has_21_distinct_cases() { model_case( &mut count, |s| s.sources[0].channels = 0, - DiagnosticCode::CapacityZero, + DiagnosticCode::SourceChannelsUnsupported, + "$.sources[0].channels", + ); + model_case( + &mut count, + |s| s.sources[0].channels = 3, + DiagnosticCode::SourceChannelsUnsupported, "$.sources[0].channels", ); model_case( @@ -535,7 +541,7 @@ fn source_identity_and_shape_category_has_21_distinct_cases() { "$.sources[0].bit_depth", ); } - assert_eq!(count, 21); + assert_eq!(count, 22); } /// #1203 D1: a submix source is `{ kind = "submix", submix_id, tap }` for a route and a sidechain diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index de1745e36..a04fe1073 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -169,7 +169,10 @@ canonical serialization never turn such a model into an engine session. It is th rate in a document; V1 has no per-source rate and no implicit sample-rate conversion. Each source is exactly `{ id, content, channels, bit_depth, frames }`. `content` must match -`blake3:[0-9a-f]{64}` exactly. `channels` and `frames` are nonzero; `frames` is the full canonical +`blake3:[0-9a-f]{64}` exactly. `channels` is 1 or 2: a source is mono or stereo (owner answer of +2026-10-15), and any other value, zero or more than `SOURCE_CHANNELS_LAUNCH_MAXIMUM`, rejects with +`source.channels.unsupported` at `$.sources[i].channels` at load and in a structural edit's +validation, on every host. `frames` is nonzero; `frames` is the full canonical content length beginning at frame zero. `bit_depth` is integer `16`, integer `24`, or the string `"32f"`; the canonical writer preserves those spellings. Locator, mapping, region, and per-source rate are not part of the schema. Host resolver policy maps the content identity to bytes, then diff --git a/hosts/host-web/src/ffi.rs b/hosts/host-web/src/ffi.rs index aa87b599c..76c23a944 100644 --- a/hosts/host-web/src/ffi.rs +++ b/hosts/host-web/src/ffi.rs @@ -3213,7 +3213,7 @@ pub extern "C" fn miso_engine_web_v1_source_submit( let id_pointer = ids.as_ptr(); for (channel, slot) in plane_slots[..channel_count].iter_mut().enumerate() { let offset = channel * quantum; - // SAFETY: Preparation allocated `SOURCE_CHANNELS_MAXIMUM * quantum` stable PCM + // SAFETY: Preparation allocated `SOURCE_CHANNELS_LAUNCH_MAXIMUM * quantum` stable PCM // samples. The checked channel bound/product and `frames <= quantum` prove this // exact plane prefix is readable for the synchronous source submission below. let plane = unsafe { slice::from_raw_parts(pcm_pointer.add(offset), frame_count) }; diff --git a/hosts/host-web/src/lib.rs b/hosts/host-web/src/lib.rs index 7f5dddaad..c266e0153 100644 --- a/hosts/host-web/src/lib.rs +++ b/hosts/host-web/src/lib.rs @@ -57,7 +57,7 @@ use host_core::{ }; #[cfg(test)] use host_core::{SpectrumCaptureCollectionRequest, SpectrumCaptureRequest}; -use session::{CompileCaps, SOURCE_CHANNELS_MAXIMUM, STABLE_ID_MAXIMUM_BYTES}; +use session::{CompileCaps, SOURCE_CHANNELS_LAUNCH_MAXIMUM, STABLE_ID_MAXIMUM_BYTES}; use host_core::EffectRack; /// Live effect addressing in the session's own terms (decision 12, issue #1096). @@ -4701,9 +4701,9 @@ fn project_buffers( ) -> Result { // The staging holds what a rebuild may add, not only what the boot session declares (#1562b): // a source, track or submix ID of the longest length the grammar allows, and a source of the - // most channels it allows. Sizing from the boot session would let an acked rebuild declare a + // most channels a source can have (two). Sizing from the boot session would let an acked rebuild declare a // source the host could never stage or feed. - let maximum_source_channels = SOURCE_CHANNELS_MAXIMUM; + let maximum_source_channels = SOURCE_CHANNELS_LAUNCH_MAXIMUM; let id_staging_bytes = STABLE_ID_MAXIMUM_BYTES as u64; let ( spectrum_configured, diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 48db80191..044cde3ed 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -72,11 +72,11 @@ fn staging_holds_the_grammars_longest_id_and_widest_source_and_refuses_an_overlo assert_eq!(buffers.source_id.len(), session::STABLE_ID_MAXIMUM_BYTES); assert_eq!( buffers.source_pcm.len(), - session::SOURCE_CHANNELS_MAXIMUM as usize * 128 + session::SOURCE_CHANNELS_LAUNCH_MAXIMUM as usize * 128 ); assert_eq!( buffers.plane_references.len(), - session::SOURCE_CHANNELS_MAXIMUM as usize + session::SOURCE_CHANNELS_LAUNCH_MAXIMUM as usize ); let longest = [b'x'; session::STABLE_ID_MAXIMUM_BYTES]; assert_eq!( @@ -503,7 +503,7 @@ fn preparation_accepts_explicit_64_128_and_256_quanta_with_stable_buffers() { // Source PCM staging holds the most channels a source can declare, not the boot session's. assert_eq!( host.source_pcm_mut().expect("PCM").len(), - session::SOURCE_CHANNELS_MAXIMUM as usize * quantum as usize + session::SOURCE_CHANNELS_LAUNCH_MAXIMUM as usize * quantum as usize ); assert_eq!( host.output_pcm().expect("output").len(), @@ -4668,8 +4668,8 @@ fn observation_unit_conversion_is_declared_and_clamped() { /// /// `zeta` is declared first and `alpha` last, so a query that reported *declaration* order rather /// than the normalized order would be visible here rather than hidden by an already-sorted -/// fixture. The shapes are all distinct -- different channel counts and lengths -- so a query that -/// read the wrong row is visible too. The one track points at `mid`, which is neither the first nor +/// fixture. The shapes are all distinct -- a source is mono or stereo, so the lengths differ -- so a +/// query that read the wrong row is visible too. The one track points at `mid`, which is neither the first nor /// the last of the three by either ordering. fn three_source_session(quantum: u32) -> String { let mut model = parse_session_json(include_str!("../tests/browser-v1/session.json")) @@ -4686,7 +4686,7 @@ fn three_source_session(quantum: u32) -> String { model.sources = vec![ source("zeta", 1, u64::from(quantum) * 3), source("mid", 2, u64::from(quantum) * 5), - source("alpha", 4, u64::from(quantum) * 2), + source("alpha", 1, u64::from(quantum) * 2), ]; model.tracks[0].source_id = session::StableId::parse("mid").expect("stable id"); canonical_session_json(&model).expect("canonical three-source session") @@ -4721,7 +4721,7 @@ fn session_source_introspection_is_canonical_ordered_shaped_and_bounded() { assert_eq!( host.session_source_shape(0).expect("alpha"), SessionSourceShape { - channel_count: 4, + channel_count: 1, frames: u64::from(QUANTUM) * 2, } ); @@ -4789,7 +4789,7 @@ fn raw_ffi_source_introspection_mirrors_the_track_queries() { miso_engine_web_v1_source_channels(handle, 1), miso_engine_web_v1_source_channels(handle, 2), ], - [4, 2, 1] + [1, 2, 1] ); assert_eq!( [ diff --git a/hosts/host-web/tests/browser-v1/expected.json b/hosts/host-web/tests/browser-v1/expected.json index e74e3c65c..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": 3014656 + "memoryBytes": 2883584 }, "beforeDisposeStatus": { "state": 2, @@ -46,7 +46,7 @@ "quantumFrames": 128, "nextAbsoluteSample": "384", "renderedQuanta": "3", - "memoryBytes": 3014656 + "memoryBytes": 2883584 }, "resources": { "sampleRateHz": 48000, @@ -57,7 +57,7 @@ "sessionDocumentBytes": "2021", "diagnosticBytes": "16384", "idStagingBytes": "127", - "sourcePcmStagingBytes": "130560", + "sourcePcmStagingBytes": "1024", "outputPcmBytes": "1024", "largestBridgeAllocationBytes": "1048576", "effectScalarStateBytes": "0", @@ -68,7 +68,7 @@ }, "resourceCeilings": { "bridgeMetadataBytes": "1628864", - "bridgeRetainedBytes": "1812352", + "bridgeRetainedBytes": "1667648", "sourceTotalBytes": "3648", "sourceOverheadBytes": "2496", "builtinRetainedBytes": "4288", @@ -114,7 +114,7 @@ "quantumFrames": 128, "nextAbsoluteSample": "1280", "renderedQuanta": "10", - "memoryBytes": 3014656 + "memoryBytes": 2883584 }, "pcmF32leSha256": "c3659578f50d02030fcef19df98be88d70e89f2a0c9bade3e23e9570864d1cfd" }, diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 9eb80af88..9a3b9cd6f 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -2021,7 +2021,11 @@ fn bound_model(tracks: usize, vcas: usize) -> SessionModel { /// What the C ABI answers a compile of `model` with: its result code and its diagnostics. A /// session that compiles is destroyed again. fn c_abi_compile(model: &SessionModel) -> (u32, Vec) { - let document = canonical_session_json(model).expect("canonical session"); + c_abi_compile_document(&canonical_session_json(model).expect("canonical session")) +} + +/// [`c_abi_compile`] for a session document that need not be one the model can write. +fn c_abi_compile_document(document: &str) -> (u32, Vec) { let config = EngineConfig { struct_size: ENGINE_CONFIG_SIZE, abi_version: C_ABI_VERSION, @@ -2066,9 +2070,16 @@ fn c_abi_compile(model: &SessionModel) -> (u32, Vec) { /// The browser's diagnostics for a boot of `model`: `None` when it boots. fn browser_boot_refusal(model: &SessionModel) -> Option> { - let document = canonical_session_json(model).expect("canonical session"); + browser_boot_refusal_document( + &canonical_session_json(model).expect("canonical session"), + model.sample_rate_hz, + ) +} + +/// [`browser_boot_refusal`] for a session document that need not be one the model can write. +fn browser_boot_refusal_document(document: &str, sample_rate_hz: u32) -> Option> { let options = host_web::WebBootOptions { - require_sample_rate_hz: model.sample_rate_hz, + require_sample_rate_hz: sample_rate_hz, require_quantum_frames: QUANTUM, source_ring_frames: QUANTUM * 2, ..host_web::WebBootOptions::explicit_defaults() @@ -2280,3 +2291,88 @@ fn a_rebuild_adds_the_longest_stereo_source_to_a_session_that_booted_with_a_mono assert!(heard, "{rate} Hz: the new source is heard"); } } + +// --------------------------------------------------------------------------------------------- +// A source is mono or stereo (owner answer of 2026-10-15) +// --------------------------------------------------------------------------------------------- + +/// Both hosts refuse a session whose source declares three channels at load, and a structural edit +/// that adds one before it commits, with the same typed code and the same reply bytes; a mono and a +/// stereo source load as before. +/// +/// Test value: red if one host's load or structural-edit path lets a wider-than-stereo source +/// through (the browser sizes its source staging for two channels, so a three-channel source it +/// accepted could never be fed), if the two hosts name different codes, or if a refused edit moves +/// the committed revision. +#[test] +fn both_hosts_refuse_a_source_of_more_than_two_channels_alike() { + for rate in RATES { + // At load. The model cannot write a three-channel source, so the document is edited. + let (model, source, ..) = mono_model(rate); + let document = canonical_session_json(&model).expect("a mono session"); + let stereo = { + let mut model = model.clone(); + model.sources[0].channels = 2; + model.tracks[0].right_source_channel = 1; + canonical_session_json(&model).expect("a stereo session") + }; + assert_eq!( + c_abi_compile_document(&document).0, + C_RESULT_OK, + "{rate} mono" + ); + assert_eq!(browser_boot_refusal_document(&document, rate), None); + assert_eq!( + c_abi_compile_document(&stereo).0, + C_RESULT_OK, + "{rate} stereo" + ); + assert_eq!(browser_boot_refusal_document(&stereo, rate), None); + let wide = stereo.replacen( + "\"channels\": 2,\n \"bit_depth\"", + "\"channels\": 3,\n \"bit_depth\"", + 1, + ); + assert_ne!(wide, stereo, "{rate}: the source declaration is found"); + let (code, c_diagnostics) = c_abi_compile_document(&wide); + assert_ne!( + code, C_RESULT_OK, + "{rate}: the C ABI refuses three channels" + ); + let browser_diagnostics = + browser_boot_refusal_document(&wide, rate).expect("the browser refuses"); + assert_eq!(c_diagnostics, browser_diagnostics, "{rate}: one diagnostic"); + assert!( + String::from_utf8_lossy(&c_diagnostics).contains("source.channels.unsupported"), + "{rate}: the typed code, got {:?}", + String::from_utf8_lossy(&c_diagnostics) + ); + + // In a structural edit: refused before commit, the revision does not move. + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + let mut wide_source = source; + wide_source.id = id("wide"); + wide_source.channels = 3; + let edits = [SessionEdit::UpsertSource { + source: wide_source, + }]; + let (verdict, b_reply) = browser.transaction_reply(&edits); + let c_reply = c_abi.send( + CommandPayload::SessionTransactionApply(&edits), + ExpectedRevision::Exact(SessionRevision(c_abi.revision)), + ); + assert_eq!(b_reply, c_reply, "{rate}: the same reply bytes"); + assert!( + matches!(&verdict, Verdict::Refused { codes, revision } + if *revision == model.revision + && codes.as_slice() == ["source.channels.unsupported"]), + "{rate}: refused at the committed revision, got {verdict:?}" + ); + assert_eq!( + browser.host.session_source_count(), + 1, + "{rate}: nothing committed" + ); + } +} diff --git a/hosts/host-web/tests/plan_exchange_threads.rs b/hosts/host-web/tests/plan_exchange_threads.rs index b91c3ab1f..28115693a 100644 --- a/hosts/host-web/tests/plan_exchange_threads.rs +++ b/hosts/host-web/tests/plan_exchange_threads.rs @@ -400,18 +400,17 @@ fn the_watermark_export_copies_the_applied_revision_and_refuses_a_wrong_handle() .expect("the watermark phase completes"); } -/// A rebuild adds a source whose ID has the longest length the grammar allows and whose channel -/// count exceeds every boot source's (#1562b review B1). Through the exports the worklet calls: -/// `source_id` copies the ID into staging (it trapped on a staging sized from the boot session), -/// the channel and frame queries answer the session map, and the source takes a six-plane chunk -/// and is heard (the staging held two planes, so the worklet's source rows failed and the source -/// could never be fed). +/// A rebuild adds a source whose ID has the longest length the grammar allows (#1562b review B1). +/// Through the exports the worklet calls: `source_id` copies the ID into staging (it trapped on a +/// staging sized from the boot session), the channel and frame queries answer the session map, and +/// the source takes a two-plane chunk and is heard. A source is mono or stereo (owner answer of +/// 2026-10-15), so the staging holds `SOURCE_CHANNELS_LAUNCH_MAXIMUM` planes and no more. /// /// Test value: red if the browser's ID or PCM staging is sized from the boot session. The parity /// row in `control_parity.rs` compares the bits against the C ABI through the host's safe API, /// which bypasses the staging; only the exports exercise it. #[test] -fn a_rebuild_adds_the_longest_id_with_more_channels_than_any_boot_source() { +fn a_rebuild_adds_the_longest_id_and_feeds_the_new_source() { let _turn = TURN.lock().expect("the turn"); std::thread::spawn(|| { let handle = boot(); @@ -419,12 +418,12 @@ fn a_rebuild_adds_the_longest_id_with_more_channels_than_any_boot_source() { let name = format!("s{}", "x".repeat(session::STABLE_ID_MAXIMUM_BYTES - 1)); let mut source = model.sources[0].clone(); source.id = StableId::parse(&name).expect("source ID"); - source.channels = 6; + source.channels = 2; let mut track = model.tracks[0].clone(); track.id = StableId::parse("wide").expect("track ID"); track.source_id = source.id.clone(); - track.left_source_channel = 4; - track.right_source_channel = 5; + track.left_source_channel = 0; + track.right_source_channel = 1; let mut route = model.routes[0].clone(); route.id = StableId::parse("wide-main").expect("route ID"); let RouteSource::Track { track_id, .. } = &mut route.source else { @@ -459,9 +458,9 @@ fn a_rebuild_adds_the_longest_id_with_more_channels_than_any_boot_source() { native_staging::staged_source_id(handle, length).as_deref(), Some(name.as_bytes()) ); - assert_eq!(host_web::miso_engine_web_v1_source_channels(handle, 1), 6); + assert_eq!(host_web::miso_engine_web_v1_source_channels(handle, 1), 2); - // Feed it a six-plane chunk of its own generation and hear it. + // Feed it a two-plane chunk of its own generation and hear it. assert!(native_staging::source_id(handle, name.as_bytes())); assert_eq!( host_web::miso_engine_web_v1_source_seek(handle, length, 2, 0), @@ -469,9 +468,9 @@ fn a_rebuild_adds_the_longest_id_with_more_channels_than_any_boot_source() { ); assert!(native_staging::source_pcm(handle, 0.5)); assert_eq!( - host_web::miso_engine_web_v1_source_submit(handle, length, 2, 0, 6, QUANTUM_FRAMES, 0), + host_web::miso_engine_web_v1_source_submit(handle, length, 2, 0, 2, QUANTUM_FRAMES, 0), RESULT_OK, - "a six-channel chunk fits the staging" + "a stereo chunk fits the staging" ); assert_eq!( host_web::miso_engine_web_v1_render(handle, QUANTUM_FRAMES), diff --git a/hosts/host-web/tests/retained_ceilings.rs b/hosts/host-web/tests/retained_ceilings.rs index 38b70dfe4..8bb94522d 100644 --- a/hosts/host-web/tests/retained_ceilings.rs +++ b/hosts/host-web/tests/retained_ceilings.rs @@ -107,6 +107,14 @@ const CAPTURE: u64 = LIVE_RESPONSE_CAPTURE_BYTES as u64; /// to 255 channels of the quantum, the plane references and the ID staging); the metadata row rose /// by the plane references alone (1,594,186 to 1,598,234) and stays under its ceiling. The new /// beyond-capture figure is 716,302; plus 10 % is 787,932.2, rounded up to 64: 787,968. +/// +/// **Re-pinned: `bridge_retained_bytes` (source channels, batch 13).** A source is mono or stereo +/// (owner answer of 2026-10-15), so the browser sizes the same staging by +/// `SOURCE_CHANNELS_LAUNCH_MAXIMUM` (two), not 255: the PCM staging is back to 2 channels of the +/// quantum and the plane references to two. The ID staging keeps its 127 bytes. For this fixture +/// the retained row fell from 1,764,878 to 1,631,294 bytes (x86-64; -133,584 and 113 for the ID +/// staging, so +113 over the row before B1), and the metadata row to 1,594,186. The new +/// beyond-capture figure is 582,718; plus 10 % is 640,989.8, rounded up to 64: 641,024. const BUDGETS: [Budget; 12] = [ Budget { row: "bridge_metadata_bytes", @@ -117,8 +125,8 @@ const BUDGETS: [Budget; 12] = [ Budget { row: "bridge_retained_bytes", value: |report| report.bridge_retained_bytes, - eight_lanes: CAPTURE + 787_968, - four_lanes: CAPTURE + 787_968, + eight_lanes: CAPTURE + 641_024, + four_lanes: CAPTURE + 641_024, }, Budget { row: "source_total_bytes", diff --git a/scripts/check-abi-layout-v1.py b/scripts/check-abi-layout-v1.py index 7c850c48d..64304bf65 100644 --- a/scripts/check-abi-layout-v1.py +++ b/scripts/check-abi-layout-v1.py @@ -478,7 +478,7 @@ def validate(document: object) -> None: require(isinstance(constants, dict), "constants is an object") require(set(constants) == { "resultCodes", "bootResultAliases", "states", "backends", "bufferKinds", - "wireCommandKinds", "commandReasons", "maximumCommandRecords", "maximumDocumentBytes", + "wireCommandKinds", "commandReasons", "maximumCommandRecords", "maximumDocumentBytes", "maximumSourceChannels", "diagnosticBytes", "defaultMeterBlocks", "maximumObservationTaps", "maximumResponsePoints", "defaultMaximumMemoryBytes", "sourceRing", "responseGrids", "responseChannels", "observationChannels", @@ -535,6 +535,7 @@ def validate(document: object) -> None: for name, expected in ( ("maximumCommandRecords", 256), ("maximumDocumentBytes", 1 << 20), + ("maximumSourceChannels", 2), ("diagnosticBytes", 1 << 14), ("defaultMeterBlocks", 12), ("maximumObservationTaps", 16), diff --git a/scripts/fixtures/abi-layout-v1-self-test.json b/scripts/fixtures/abi-layout-v1-self-test.json index e54b8a08f..0c685ce92 100644 --- a/scripts/fixtures/abi-layout-v1-self-test.json +++ b/scripts/fixtures/abi-layout-v1-self-test.json @@ -472,6 +472,7 @@ "spectrumStreamStatuses": [{ "value": 0, "name": "inactive" }, { "value": 1, "name": "warming" }, { "value": 2, "name": "pending" }, { "value": 3, "name": "gap" }, { "value": 4, "name": "failed" }, { "value": 5, "name": "stopped" }, { "value": 6, "name": "ready" }], "maximumCommandRecords": 256, "maximumDocumentBytes": 1048576, + "maximumSourceChannels": 2, "diagnosticBytes": 16384, "defaultMeterBlocks": 12, "maximumObservationTaps": 16, diff --git a/sdk/assets/miso-engine-v1-abi-layout.json b/sdk/assets/miso-engine-v1-abi-layout.json index e54b8a08f..0c685ce92 100644 --- a/sdk/assets/miso-engine-v1-abi-layout.json +++ b/sdk/assets/miso-engine-v1-abi-layout.json @@ -472,6 +472,7 @@ "spectrumStreamStatuses": [{ "value": 0, "name": "inactive" }, { "value": 1, "name": "warming" }, { "value": 2, "name": "pending" }, { "value": 3, "name": "gap" }, { "value": 4, "name": "failed" }, { "value": 5, "name": "stopped" }, { "value": 6, "name": "ready" }], "maximumCommandRecords": 256, "maximumDocumentBytes": 1048576, + "maximumSourceChannels": 2, "diagnosticBytes": 16384, "defaultMeterBlocks": 12, "maximumObservationTaps": 16, diff --git a/sdk/src/core/session.ts b/sdk/src/core/session.ts index 7db5c49ea..ba085b966 100644 --- a/sdk/src/core/session.ts +++ b/sdk/src/core/session.ts @@ -1,3 +1,4 @@ +import { ABI_LAYOUT } from "../generated/abi.ts"; import { CATALOG } from "../generated/catalog.ts"; import { CONTROLS } from "../generated/controls.ts"; import type { ControlLane } from "../generated/controls.ts"; @@ -676,8 +677,10 @@ export class SessionBuilder { fail(`${path}.id`, "a source with this ID is already declared"); } if (spec === null || typeof spec !== "object") fail(path, "expected a source specification"); - if (spec.channels !== 1 && spec.channels !== 2) { - fail(`${path}.channels`, "a V1 source is mono or dual-mono"); + // The engine's own limit, from its generated ABI constants: a source is mono or stereo. + const maximumChannels = ABI_LAYOUT.constants.maximumSourceChannels; + if (!Number.isInteger(spec.channels) || spec.channels < 1 || spec.channels > maximumChannels) { + fail(`${path}.channels`, `a V1 source has 1 to ${maximumChannels} channels (mono or stereo)`); } if (spec.bitDepth !== 16 && spec.bitDepth !== 24 && spec.bitDepth !== "32f") { fail(`${path}.bitDepth`, 'expected the token 16, 24 or "32f"'); diff --git a/sdk/src/generated/abi.ts b/sdk/src/generated/abi.ts index a8ed3d392..7bd054526 100644 --- a/sdk/src/generated/abi.ts +++ b/sdk/src/generated/abi.ts @@ -1789,6 +1789,7 @@ export const ABI_LAYOUT = deepFreeze( ], "maximumCommandRecords": 256, "maximumDocumentBytes": 1048576, + "maximumSourceChannels": 2, "diagnosticBytes": 16384, "defaultMeterBlocks": 12, "maximumObservationTaps": 16, diff --git a/sdk/test/builder-evals.mjs b/sdk/test/builder-evals.mjs index d7bd891da..963541d1b 100644 --- a/sdk/test/builder-evals.mjs +++ b/sdk/test/builder-evals.mjs @@ -364,6 +364,26 @@ describe("validation refusals name the offending path", () => { ); }); + test("a source of other than one or two channels is refused at its own path", () => { + // The limit is the engine's own, read from its generated ABI constants (a source is mono or + // stereo). Red mutation: hard-code a wider limit in the builder and a three-channel source is + // built, then refused only at boot. + for (const channels of [0, 3, 255, 1.5]) { + assert.throws( + () => + session({ id: "wide", sampleRateHz: 48_000 }) + .source("stem", { channels, bitDepth: 16, frames: 480, content: CONTENT_A }), + (error) => { + assert.ok(error instanceof MisoUsageError); + assert.match(error.message, /source\("stem"\)\.channels/); + assert.match(error.message, /1 to 2 channels/); + return true; + }, + `channels ${channels}`, + ); + } + }); + test("a malformed content digest is refused at its own path", () => { // The schema is exact: `blake3:` and 64 lowercase hex digits. Red mutation: relax the regex to // a prefix check, and every one of these becomes a boot-time refusal instead. diff --git a/tools/parameter-metadata/src/abi_layout.rs b/tools/parameter-metadata/src/abi_layout.rs index 4ce648186..0660aa895 100644 --- a/tools/parameter-metadata/src/abi_layout.rs +++ b/tools/parameter-metadata/src/abi_layout.rs @@ -1625,6 +1625,11 @@ pub fn render() -> String { &mut out, " \"maximumDocumentBytes\": {MAXIMUM_DOCUMENT_BYTES},\n" ); + append!( + &mut out, + " \"maximumSourceChannels\": {},\n", + session::SOURCE_CHANNELS_LAUNCH_MAXIMUM + ); append!(&mut out, " \"diagnosticBytes\": {DIAGNOSTIC_BYTES},\n"); append!( &mut out, From 0642b258faf57401cde1b6c626dc7f3777b0a75c Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 13:11:12 +0000 Subject: [PATCH 04/12] Copy a successor's armed taps from the mask the core prepared (#1679) EffectObservationHandle carries the armed tap mask its prepared arm set (armed_taps), and the browser's build_plan_shape copies it; the duplicate binding match in host-web is deleted. Co-Authored-By: Claude Opus 5.5 --- ...xpose-the-armed-tap-mask-from-host-core.md | 6 ++ crates/effect-compiler/src/prepare.rs | 12 ++- hosts/host-web/src/lib.rs | 41 ++----- hosts/host-web/src/tests.rs | 100 ++++++++++++++++++ 4 files changed, 125 insertions(+), 34 deletions(-) diff --git a/.github/ISSUE_SPECS/1679-expose-the-armed-tap-mask-from-host-core.md b/.github/ISSUE_SPECS/1679-expose-the-armed-tap-mask-from-host-core.md index 9f3e1d85c..6cad70b0a 100644 --- a/.github/ISSUE_SPECS/1679-expose-the-armed-tap-mask-from-host-core.md +++ b/.github/ISSUE_SPECS/1679-expose-the-armed-tap-mask-from-host-core.md @@ -31,3 +31,9 @@ match, so one function decides which tap a binding arms. ## Size A few hours. + +## Decision record: expose the armed tap mask (batch 13a) + +- `EffectObservationHandle` is defined in `crates/effect-compiler/src/prepare.rs` (host-core re-exports it), so the field `armed_taps: u32` (bit `i` = `descriptor.observations[i]`) was added there. `attach_effect_observation` sets a bit exactly when `ObservationLane::arm_prepared` armed that tap, so the mask is the lane's own arming, not a second match. +- host-web's `build_plan_shape` copies `handle.armed_taps` into `observation_armed`; the duplicate binding match and its `bindings` parameter (and the caller's lookup) are deleted. An arm for a tap past bit 31 has no bit, as before. +- Gate: `tests::structural_apply::the_browsers_armed_bits_are_the_mask_host_core_prepared` (a console slot `comp` and insert `comp` on two tracks, one insert and one console tap armed, restart rebuild; every slot's word equals the handle's mask; 4 observed, 2 armed). It goes red when the copy is replaced by 0. The #1584 D6b browser tests pass unchanged. diff --git a/crates/effect-compiler/src/prepare.rs b/crates/effect-compiler/src/prepare.rs index 2566d93eb..575328eac 100644 --- a/crates/effect-compiler/src/prepare.rs +++ b/crates/effect-compiler/src/prepare.rs @@ -1749,6 +1749,11 @@ pub struct EffectObservationHandle { pub descriptor: &'static EffectDescriptor, /// One reader per declared tap, in declaration order. pub readers: Box<[ObservationReader]>, + /// The taps this instance was prepared with armed (#1584 D6a), one bit per declared tap: bit + /// `i` is `descriptor.observations[i]`, the effect-local tap index `push_observe` sets. This is + /// the one place a binding is matched to a tap, so a host reads the arming from here and does + /// not repeat the match. A tap past bit 31 has no bit. + pub armed_taps: u32, } /// One observation tap a successor plan is prepared with armed (#1584 D6a): the host acknowledged @@ -1856,6 +1861,7 @@ pub fn attach_effect_observation( // first window at its first rendered block. An arm for a tap the effect does not declare // is ignored: the host drops such a binding before it reaches here. let console = !matches!(entry.rack, EffectRack::Dynamic); + let mut armed_taps = 0_u32; if let Some(taps) = armed.get(&(entry.track_id.as_str(), console, entry.effect_id.as_str())) { for &(tap_id, window) in taps { @@ -1863,8 +1869,11 @@ pub fn attach_effect_observation( .observations .iter() .position(|tap| tap.id.0 == tap_id) + && let Ok(index) = u32::try_from(index) + && lane.arm_prepared(index, window) + && let Some(bit) = 1_u32.checked_shl(index) { - lane.arm_prepared(index as u32, window); + armed_taps |= bit; } } } @@ -1874,6 +1883,7 @@ pub fn attach_effect_observation( effect_id: entry.effect_id.as_str().into(), descriptor, readers: readers.into_boxed_slice(), + armed_taps, }); entry.observation = Some(Box::new(lane)); } diff --git a/hosts/host-web/src/lib.rs b/hosts/host-web/src/lib.rs index 7f5dddaad..ba7f2ed8e 100644 --- a/hosts/host-web/src/lib.rs +++ b/hosts/host-web/src/lib.rs @@ -5518,11 +5518,6 @@ impl WebRuntimePreparer { effects: lanes.effects || live_controls.observation_taps != 0, ..lanes }; - // #1584 D6b: the taps the host had armed, which host-core arms the successor's lanes from; - // the plan's tables must say the same. - let bindings: &[controls::ObservationBinding] = successor - .map(|base| base.observation.bindings()) - .unwrap_or_default(); let (mut host, mut handles, spectrum_captures) = prepare_web_host( compiled, caps, @@ -5542,7 +5537,6 @@ impl WebRuntimePreparer { &handles.effect_observations, handles.meters.len(), handles.master_track, - bindings, ) .map_err(|code| { let rejection = if code.starts_with("web.resource.") { @@ -5634,10 +5628,9 @@ impl WebRuntimePreparer { /// model's, the order host-core's addressing authority uses and the control plane's producers /// follow. /// -/// `bindings` are the taps the host had armed when this plan was prepared (#1584 D6b): the same -/// table host-core armed the plan's lanes from, so a bound tap's `observation_armed` bit is set -/// here with arm sample 0 and its commanded bit clear (a fresh tap has no stale window). A binding -/// whose effect or tap the plan lacks sets nothing, as host-core arms nothing for it. +/// A tap the host had armed when this plan was prepared (#1584 D6b) has its `observation_armed` +/// bit set here, copied from the observation handle's `armed_taps` (host-core's own match), with +/// arm sample 0 and its commanded bit clear (a fresh tap has no stale window). /// /// Errors are the diagnostic code of the refusal. fn build_plan_shape( @@ -5646,7 +5639,6 @@ fn build_plan_shape( observations: &[EffectObservationHandle], meter_count: usize, master_track: Option, - bindings: &[controls::ObservationBinding], ) -> Result { let track_count = model.tracks.len(); let submix_count = model.submixes.len(); @@ -5799,28 +5791,11 @@ fn build_plan_shape( .map_err(|_| "web.resource.allocation")?; arm_samples.resize(tap_count, 0); observation_arm_samples[slot] = arm_samples.into_boxed_slice(); - // #1584 D6b: the bits of the taps the host had armed, in the descriptor's tap order (the - // bit is the effect-local tap index, as `push_observe` sets it). - let console = handle.address.rack == LiveEffectRack::Console; - for binding in bindings { - if binding.key.section.is_console() != console - || binding.key.section.id().as_str() != &*handle.effect_id - || binding.key.strip.as_str() != &*handle.track_id - { - continue; - } - if let Some(index) = handle - .descriptor - .observations - .iter() - .position(|tap| tap.id.0 == binding.key.tap_id) - && let Some(bit) = u32::try_from(index) - .ok() - .and_then(|index| 1_u32.checked_shl(index)) - && let Some(word) = observation_armed.get_mut(slot) - { - *word |= bit; - } + // #1584 D6b: the taps the host had armed are the ones host-core armed in this plan's + // lanes, in the descriptor's tap order (the bit is the effect-local tap index, as + // `push_observe` sets it); host-core made the match, so it is copied, not repeated. + if let Some(word) = observation_armed.get_mut(slot) { + *word = handle.armed_taps; } instance.reader = Some(u32::try_from(reader).map_err(|_| "web.live_controls.observation")?); reader_slots.push(u32::try_from(slot).map_err(|_| "web.live_controls.observation")?); diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 48db80191..e9f03efbf 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -7786,6 +7786,106 @@ mod structural_apply { ); } + /// #1584 D6b via #1679: the successor's `observation_armed` word of every observed effect is + /// the `armed_taps` host-core prepared the handle with. The session has one compressor `comp` + /// as a console slot and as an insert on each of two tracks (an ID reused for another effect + /// in the other rack and on another strip); the host armed the first track's insert and the + /// second track's console slot, and a restart rebuilds both. + /// + /// Test value: red if the browser matches bindings to taps by its own rule again and that rule + /// differs from host-core's (an ID match that ignores the rack or the strip would arm the + /// console slot of the first track, or the insert of the second, in the page's tables only). + #[test] + fn the_browsers_armed_bits_are_the_mask_host_core_prepared() { + let mut model = observed_model(); + model.console.pre_insert = vec![session::ConsoleSlot { + slot: StableId::parse("comp").expect("slot ID"), + identity: session::EffectIdentity::Native { + effect_id: StableId::parse("miso.compressor").expect("effect ID"), + }, + quality: session::EffectQuality::Normal, + link_mode: session::LinkMode::DualMono, + }]; + model.tracks[0].console = model + .console + .slots() + .map(|slot| session::ConsoleEntry { + slot: slot.slot.clone(), + bypass: false, + params: Vec::new(), + }) + .collect(); + let (mut second, route) = muted_track(&model, "zz"); + second.fader.left_mute = false; + second.fader.right_mute = false; + model.tracks.push(second); + model.routes.push(route); + let mut rig = Rig::boot(&model, options(), None); + rig.step(); + assert_eq!( + observe(&mut rig.host, 0, RACK_INSERTS, 0, 1, 2, true), + RESULT_OK + ); + assert_eq!( + observe(&mut rig.host, 1, RACK_CONSOLE, 0, 1, 2, true), + RESULT_OK + ); + for _ in 0..4 { + rig.step(); + } + assert_eq!(rig.host.observation_armed_taps(), 2); + + let mut restarted = model.tracks[0].inserts.effects[0].clone(); + restarted.link_mode = session::LinkMode::Maximum; + let base = rig.host.committed_revision(); + assert!(matches!( + rig.apply( + &[SessionEdit::PutTrackEffect { + track_id: model.tracks[0].id.clone(), + rack_name: session::RackName::Inserts, + final_position: 0, + effect: restarted, + }], + 1 + ), + Outcome::Applied { + path: EditPath::Rebuild, + revision + } if revision == base + 1 + )); + rig.step(); + assert_eq!(rig.epoch(), 1); + let slots = rig + .host + .ready + .as_ref() + .expect("ready") + .shape + .observation_armed + .len(); + let (mut observed, mut armed) = (0, 0); + for slot in 0..slots { + let Some(mask) = rig.host.test_observation_handle(slot).map(|h| h.armed_taps) else { + continue; + }; + let word = rig + .host + .ready + .as_ref() + .expect("ready") + .shape + .observation_armed[slot]; + assert_eq!(word, mask, "slot {slot}: the page copies host-core's mask"); + observed += 1; + armed += mask.count_ones(); + } + assert_eq!( + observed, 4, + "two inserts and two console slots are observed" + ); + assert_eq!(armed, 2, "only the two acknowledged taps are armed"); + } + /// #1584 D6b, removal: the removed insert has no slot on the successor, the browser's read /// finds no tap there, and the event drain returns the commit and one `OBSERVATION_ENDED` /// that names the tap. From a66f6fab2ac0b6c309e7d96eab9bc984beae0b6b Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 13:34:48 +0000 Subject: [PATCH 05/12] Boot an empty session on the core and the C ABI (TBD30, core part) Owner answer 2026-10-15: an app opens an empty mixer and adds stems by structural edits. The source graph no longer refuses a session with no source (SourceGraphSourceSetError::EmptySources deleted); an empty session prepares, renders silence with zero render allocations, and a structural transaction adds the first stereo stem and its track, which is heard. TBD29 follow-up: HostPrepareCaps::maximum_source_channels (always None) is deleted. Co-Authored-By: Claude Opus 5.5 --- ...D30-boot-an-empty-session-on-both-hosts.md | 37 + crates/capi/tests/empty_boot.rs | 647 ++++++++++++++++++ crates/capi/tests/resource_lifecycle.rs | 1 - crates/control-plane/src/compile.rs | 3 +- .../host-core/src/limiter_linked_session.rs | 1 - crates/host-core/src/prepare.rs | 8 - crates/host-core/src/response.rs | 1 - crates/host-core/tests/MUTATIONS.md | 5 - crates/host-core/tests/collapse_arming.rs | 1 - .../host-core/tests/effect_live_controls.rs | 1 - crates/host-core/tests/effect_observation.rs | 1 - crates/host-core/tests/empty_boot.rs | 147 ++++ crates/host-core/tests/fp_environment.rs | 1 - .../tests/input_liveness_live_controls.rs | 1 - .../host-core/tests/launch_registry_once.rs | 1 - crates/host-core/tests/live_addressing.rs | 1 - crates/host-core/tests/live_delta.rs | 1 - crates/host-core/tests/live_lanes.rs | 1 - crates/host-core/tests/live_routes.rs | 1 - crates/host-core/tests/live_transaction.rs | 1 - crates/host-core/tests/monitor_tick.rs | 1 - crates/host-core/tests/prepare.rs | 22 - crates/host-core/tests/randomized.rs | 1 - crates/host-core/tests/route_mute.rs | 1 - crates/host-core/tests/source_in_place.rs | 1 - crates/host-core/tests/spectrum.rs | 1 - crates/host-core/tests/spectrum_service.rs | 1 - crates/host-core/tests/stored_automation.rs | 1 - crates/host-core/tests/strip_controls.rs | 1 - crates/host-core/tests/strip_handles.rs | 1 - crates/host-core/tests/strip_meters.rs | 1 - crates/host-core/tests/submix_caps.rs | 1 - crates/host-core/tests/submix_strip.rs | 1 - crates/host-core/tests/support/successor.rs | 1 - crates/host-core/tests/symmetry_witness.rs | 1 - crates/host-core/tests/track_delay.rs | 1 - crates/host-core/tests/vca.rs | 1 - crates/host-core/tests/vca_caps.rs | 1 - crates/source/src/lib.rs | 4 - docs/REALTIME_DEPENDENCY_POLICY.md | 8 +- hosts/host-web/src/lib.rs | 1 - tools/realtime-policy/src/policy.rs | 3 +- 42 files changed, 841 insertions(+), 75 deletions(-) create mode 100644 crates/capi/tests/empty_boot.rs create mode 100644 crates/host-core/tests/empty_boot.rs diff --git a/.github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md b/.github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md index 31924643d..ef723c4e2 100644 --- a/.github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md +++ b/.github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md @@ -48,3 +48,40 @@ a structural edit can then add a stereo stem with its track; both hosts render i ## Size Half a day to a day. + +## Decision record: core and C ABI part (batch 8) + +What shipped: + +- The one refusal on the path was `prepare_graph_source_set` (`crates/source/src/lib.rs`), which + returned `SourceGraphSourceSetError::EmptySources` for a session with no source; the variant and + its check are deleted. The session validator, the graph compiler, the shape report, the meter + and control-list builders, the plan swap and the C ABI staging already handled zero strips: the + empty boot needed no other change, and no host adapter has a special path (D1). Every + boot-time size that depends on the strip count was exercised at zero by the gates below and + needed no fix (D2); the successor path grows from empty to one strip and shrinks back. +- From TBD29: `HostPrepareCaps::maximum_source_channels` and its `host.source.channels` check are + deleted (the validator rule is the one limit), with its test `source_channel_cap_is_optional`, + its `MUTATIONS.md` row M-06 and the stale doc line; callers dropped the field. + +Gates: + +- `crates/host-core/tests/empty_boot.rs`: `an_empty_session_prepares_and_renders_silence` (the + console-slot-only mixer and `canonical-minimal.json`), `declared_submixes_and_vcas_do_not_make_an_empty_session_noisy` + (an unfed bus and a VCA over it). Each renders four blocks of `+0.0` with zero render + allocations. +- `crates/capi/tests/empty_boot.rs` (its own binary, `bench_support`'s allocator): + `an_empty_session_boots_and_renders_silence_without_allocating` at 44.1, 48, 88.2 and 96 kHz: + resource row (0 sources, 0 tracks), boot watermark, silence with zero render allocations, control + list (the session header only, no strip), counters, snapshot, and a render-peak meter that reads + `0.0`; `a_structural_transaction_adds_the_first_stem_to_an_empty_session` at the same rates: one + `SESSION_TRANSACTION_APPLY` (source, track, route) takes the rebuild path, the swap block + allocates nothing, the structure epoch and the control list grow, the fed stem is heard, and a + second transaction removes it again (silence, counts back to zero). The browser parity row + mirrors this file. + +Departures: `crates/capi/tests/empty_boot.rs` joins the realtime policy's unsafe allowlist +(`tools/realtime-policy/src/policy.rs`, `docs/REALTIME_DEPENDENCY_POLICY.md`) as every capi test +binary that calls the C entries does. Otherwise none. Browser part (empty-boot sizes, parity row, qualification leg) stays with lane W; +the stale remark "A session with no source or track does not prepare" in +`hosts/host-web/tests/control_parity.rs` (`mono_model`) is theirs to drop. diff --git a/crates/capi/tests/empty_boot.rs b/crates/capi/tests/empty_boot.rs new file mode 100644 index 000000000..d5d8326f3 --- /dev/null +++ b/crates/capi/tests/empty_boot.rs @@ -0,0 +1,647 @@ +//! TBD30: a session with no source and no track boots through the C ABI (owner answer of +//! 2026-10-15), and a structural transaction then adds a stereo stem with its track. +//! +//! * **Boot.** The empty session (only the output and one declared console slot) compiles. Its +//! plan reports no source and no track, renders silence at the output with zero render +//! allocations, and the resource row, the watermark, the control list, the counters and the +//! snapshot all describe the same empty mixer. +//! * **Growth.** One `SESSION_TRANSACTION_APPLY` that adds a stereo source, a track and its route +//! takes the rebuild path (the successor is prepared off the render thread). The swap block +//! allocates nothing, the control list grows by the strip's rows, and a fed stem is heard. +//! +//! This file has its own test binary so that `bench_support`'s audited allocator serves it. The +//! browser leg mirrors it as a parity row (`hosts/host-web/tests/control_parity.rs`). + +#![allow(unsafe_code)] + +use core::ptr; + +use bench_support::alloc::{assert_installed, current_thread_counters, current_thread_delta_since}; +use capi::*; +use protocol::{ + CommandPayload, ControlListRequest, CounterId, CountersRequest, DecodeScratch, + DecodedSuccessResponsePayload, DecodedTypedResponseFrame, EditPath, ExpectedRevision, + ProtocolCodec, RequestId, SessionEdit, SessionRevision, StatusCode, TypedCommandFrame, +}; +use session::{SessionModel, StableId}; + +const FIXTURE: &str = include_str!("../../../fixtures/session/v1/parametric-eq-nine-track.json"); +const RATES: [u32; 4] = [44_100, 48_000, 88_200, 96_000]; +const QUANTUM: u32 = 128; +const FRAME_BYTES: usize = 65_536; + +fn id(text: &str) -> StableId { + StableId::parse(text).expect("stable ID") +} + +/// The fixture cut to an empty mixer: no source, no track, no route. The console slot stays +/// declared, so the track added later carries it. +fn empty_model(rate: u32) -> SessionModel { + let mut model = session::parse_session_json(FIXTURE).expect("fixture"); + model.sample_rate_hz = rate; + model.quantum_frames = QUANTUM; + model.sources.clear(); + model.tracks.clear(); + model.routes.clear(); + assert_eq!(model.console.pre_insert.len(), 1, "a declared console slot"); + model +} + +/// The stereo source, the track that reads it and the route of that track into the output. +fn stem_edits(rate: u32) -> Vec { + let fixture = session::parse_session_json(FIXTURE).expect("fixture"); + let mut source = fixture.sources[0].clone(); + source.id = id("stem"); + source.frames = u64::from(QUANTUM) * 64; + let mut track = fixture.tracks[0].clone(); + track.id = id("t1"); + track.source_id = id("stem"); + let mut route = fixture.routes[0].clone(); + route.id = id("t1-main"); + let session::RouteSource::Track { track_id, .. } = &mut route.source else { + panic!("the fixture routes a track") + }; + *track_id = id("t1"); + let _ = rate; + vec![ + SessionEdit::UpsertSource { source }, + SessionEdit::UpsertTrack { track }, + SessionEdit::UpsertRoute { route }, + ] +} + +fn limits() -> CompileLimits { + CompileLimits { + struct_size: COMPILE_LIMITS_SIZE, + source_ring_frames: 1_024, + maximum_automation_spans_per_block: 128, + reserved0: 0, + maximum_document_bytes: 1_000_000, + maximum_diagnostic_bytes: 4_096, + maximum_tracks: 100, + maximum_sources: 100, + maximum_routes: 100, + maximum_effects: 100, + maximum_graph_session_plus_plan_bytes: 100_000_000, + maximum_source_total_bytes: 10_000_000, + maximum_source_overhead_bytes: 10_000_000, + maximum_effect_state_bytes: 100_000_000, + maximum_effect_scratch_bytes: 100_000_000, + maximum_builtin_retained_bytes: 100_000_000, + maximum_capi_retained_bytes: 1 << 30, + maximum_named_allocation_bytes: 1 << 30, + maximum_meter_streams: 1, + maximum_meter_items: 1, + maximum_meter_bytes: 1, + maximum_control_frame_bytes: FRAME_BYTES as u64, + maximum_replay_bytes: 1 << 20, + maximum_replay_entries: 16, + maximum_submixes: 0, + maximum_vcas: 0, + reserved: [0; 2], + } +} + +/// One compiled session and its plan. +struct Endpoint { + session: *mut Session, + plan: *mut Plan, + revision: u64, + request: u64, +} + +impl Endpoint { + fn boot(model: &SessionModel) -> Self { + let document = session::canonical_session_json(model).expect("canonical session"); + let config = EngineConfig { + struct_size: ENGINE_CONFIG_SIZE, + abi_version: ABI_VERSION, + reserved: [0; 4], + }; + let mut engine = ptr::null_mut(); + let mut session = ptr::null_mut(); + let mut plan = ptr::null_mut(); + let mut storage = [0_u8; 4_096]; + let mut diagnostics = BytesOut { + struct_size: BYTES_OUT_SIZE, + reserved0: 0, + data: storage.as_mut_ptr(), + capacity_bytes: storage.len() as u64, + required_bytes: 0, + }; + // SAFETY: Every descriptor and output location lives for the complete call, and the engine + // handle is destroyed once after compilation. + unsafe { + assert_eq!( + miso_engine_v1_engine_create(&config, &mut engine), + RESULT_OK + ); + assert_eq!( + miso_engine_v1_compile_session( + engine, + document.as_ptr(), + document.len() as u64, + &limits(), + &mut diagnostics, + &mut session, + &mut plan, + ), + RESULT_OK, + "{}", + String::from_utf8_lossy(&storage[..diagnostics.required_bytes as usize]) + ); + miso_engine_v1_engine_destroy(engine); + } + Self { + session, + plan, + revision: model.revision, + request: 0, + } + } + + /// One command through the exported entry; the response frame. + fn send(&mut self, payload: CommandPayload<'_>, expected: ExpectedRevision) -> Vec { + self.request += 1; + let mut request = vec![0_u8; FRAME_BYTES]; + let len = ProtocolCodec::default() + .encode_command_frame_into( + &TypedCommandFrame { + request_id: RequestId::new(self.request).expect("nonzero request"), + expected_revision: expected, + payload, + }, + &mut request, + ) + .expect("command frame"); + let mut response = vec![0_u8; FRAME_BYTES]; + let mut output = BytesOut { + struct_size: BYTES_OUT_SIZE, + reserved0: 0, + data: response.as_mut_ptr(), + capacity_bytes: response.len() as u64, + required_bytes: 0, + }; + // SAFETY: The session is live and used by this thread alone; every buffer is owned. + let code = unsafe { + miso_engine_v1_submit_command(self.session, request.as_ptr(), len as u64, &mut output) + }; + assert_eq!(code, RESULT_OK, "submit"); + response.truncate(output.required_bytes as usize); + response + } + + /// The control list as `(structure epoch, rows)`, read page by page. + fn control_list(&mut self) -> (u64, Vec) { + let mut rows = Vec::new(); + let (mut epoch, mut after) = (0, 0); + loop { + let request = ControlListRequest { + structure_epoch: epoch, + after_handle: after, + limit: 256, + owner: None, + }; + let bytes = self.send( + CommandPayload::ControlListGet(&request), + ExpectedRevision::Any, + ); + let mut fields = [0_u16; 4096]; + let DecodedTypedResponseFrame::Success { payload, .. } = ProtocolCodec::default() + .decode_typed_response(&bytes, &mut DecodeScratch::new(&mut fields)) + .expect("a control page") + else { + panic!("expected a control page") + }; + let DecodedSuccessResponsePayload::ControlListPage(page) = payload else { + panic!("expected a control list page") + }; + rows.extend(page.entries.iter().map(|entry| format!("{entry:?}"))); + if page.eof { + return (page.structure_epoch, rows); + } + (epoch, after) = (page.structure_epoch, page.last_handle); + } + } + + /// One counter's value. + fn counter(&mut self, counter: CounterId) -> u64 { + let bytes = self.send( + CommandPayload::CountersGet(&CountersRequest { + all: false, + ids: vec![counter as u32], + }), + ExpectedRevision::Any, + ); + let mut fields = [0_u16; 64]; + let DecodedTypedResponseFrame::Success { + payload: DecodedSuccessResponsePayload::CounterSnapshot(snapshot), + .. + } = ProtocolCodec::default() + .decode_typed_response(&bytes, &mut DecodeScratch::new(&mut fields)) + .expect("counters response") + else { + panic!("expected a counter snapshot") + }; + assert_eq!(snapshot.values.len(), 1); + snapshot.values[0].value + } + + /// The canonical snapshot document the session reports. + fn snapshot(&mut self) -> (u64, String) { + let bytes = self.send( + CommandPayload::SessionSnapshotGet(protocol::SessionSnapshotRequest { + offset: 0, + maximum_bytes: 60_000, + }), + ExpectedRevision::Any, + ); + let mut fields = [0_u16; 64]; + let DecodedTypedResponseFrame::Success { + header, + payload: DecodedSuccessResponsePayload::SessionSnapshot(snapshot), + } = ProtocolCodec::default() + .decode_typed_response(&bytes, &mut DecodeScratch::new(&mut fields)) + .expect("snapshot response") + else { + panic!("expected a session snapshot") + }; + assert!(snapshot.eof, "the snapshot fits one chunk"); + ( + header.revision.0, + String::from_utf8(snapshot.canonical_json_chunk.to_vec()).expect("utf-8"), + ) + } + + /// The peaks of every meter record on the lossy lane. + fn meter_peaks(&mut self) -> Vec { + let mut peaks = Vec::new(); + loop { + let mut event = vec![0_u8; FRAME_BYTES]; + let mut out = BytesOut { + struct_size: BYTES_OUT_SIZE, + reserved0: 0, + data: event.as_mut_ptr(), + capacity_bytes: event.len() as u64, + required_bytes: 0, + }; + // SAFETY: The session is live and used by this thread alone; the buffer is owned. + let code = + unsafe { miso_engine_v1_dequeue_event(self.session, EVENT_LANE_LOSSY, &mut out) }; + assert_eq!(code, RESULT_OK, "dequeue"); + if out.required_bytes == 0 { + return peaks; + } + let mut fields = [0_u16; 64]; + let decoded = ProtocolCodec::default() + .decode_typed_event( + &event[..out.required_bytes as usize], + &mut DecodeScratch::new(&mut fields), + ) + .expect("a lossy event"); + if let protocol::DecodedEventPayload::MeterBatch(batch) = decoded.payload { + for index in 0..usize::from(batch.count) { + peaks.push(batch.record(index).expect("a meter record").value); + } + } + } + } + + fn resources(&self) -> PlanResourceReport { + // SAFETY: A zeroed report is a valid value whose size field is then set for the write. + unsafe { + let mut report: PlanResourceReport = core::mem::zeroed(); + report.struct_size = PLAN_RESOURCE_REPORT_SIZE; + assert_eq!( + miso_engine_v1_plan_resources(self.plan, &mut report), + RESULT_OK + ); + report + } + } + + fn watermark(&self) -> Watermark { + // SAFETY: As in `resources`. + unsafe { + let mut watermark: Watermark = core::mem::zeroed(); + watermark.struct_size = WATERMARK_SIZE; + assert_eq!( + miso_engine_v1_plan_watermark(self.plan, &mut watermark), + RESULT_OK + ); + watermark + } + } + + /// Renders block `block`; the output's bits. + fn render(&mut self, block: u64) -> Vec { + let mut pcm = vec![f32::NAN; QUANTUM as usize * 2]; + let output = PlanarOutput { + struct_size: PLANAR_OUTPUT_SIZE, + channels: 2, + samples: pcm.as_mut_ptr(), + sample_capacity: pcm.len() as u64, + frames: QUANTUM, + plane_stride_samples: QUANTUM, + reserved: [0; 2], + }; + // SAFETY: This thread is the plan's only renderer; `output` is owned storage. + let code = unsafe { + miso_engine_v1_render_f32_planar(self.plan, block * u64::from(QUANTUM), &output) + }; + assert_eq!(code, RESULT_OK, "render"); + pcm.iter().map(|sample| sample.to_bits()).collect() + } + + /// Renders one block with the render thread's allocator counters around the call alone. + fn render_counted(&mut self, block: u64) -> (Vec, u64, u64) { + let mut pcm = [f32::NAN; QUANTUM as usize * 2]; + let output = PlanarOutput { + struct_size: PLANAR_OUTPUT_SIZE, + channels: 2, + samples: pcm.as_mut_ptr(), + sample_capacity: pcm.len() as u64, + frames: QUANTUM, + plane_stride_samples: QUANTUM, + reserved: [0; 2], + }; + let mark = current_thread_counters(); + // SAFETY: This thread is the plan's only renderer; `output` is owned storage. + let code = unsafe { + miso_engine_v1_render_f32_planar(self.plan, block * u64::from(QUANTUM), &output) + }; + let moved = current_thread_delta_since(mark); + assert_eq!(code, RESULT_OK, "render"); + ( + pcm.iter().map(|sample| sample.to_bits()).collect(), + moved.allocations, + moved.deallocations, + ) + } + + /// Offers `stem` one block of constant stereo PCM, `block` blocks into its generation. + fn feed(&mut self, generation: u64, block: u64, rate: u32) { + let left = [0.25_f32; QUANTUM as usize]; + let right = [-0.5_f32; QUANTUM as usize]; + let planes = [left.as_ptr(), right.as_ptr()]; + let chunk = SourceChunk { + struct_size: SOURCE_CHUNK_SIZE, + sample_rate_hz: rate, + generation, + start_frame: block * u64::from(QUANTUM), + planes: planes.as_ptr(), + plane_count: 2, + frames: QUANTUM, + end_of_region: 0, + reserved0: 0, + }; + let mut report = SubmitReport { + struct_size: SUBMIT_REPORT_SIZE, + reserved0: 0, + accepted_frames: 0, + cumulative_written_frames: 0, + active_generation: 0, + }; + // SAFETY: The session is live and used by this thread alone; the planes and the report + // are owned storage that outlives the synchronous call. + let code = unsafe { + miso_engine_v1_source_submit_planar_f32( + self.session, + b"stem".as_ptr(), + 4, + &chunk, + &mut report, + ) + }; + assert_eq!(code, RESULT_OK, "feed"); + assert_eq!(report.accepted_frames, u64::from(QUANTUM)); + } +} + +impl Drop for Endpoint { + fn drop(&mut self) { + // SAFETY: These are the exact live handles `boot` returned, destroyed once. + unsafe { + miso_engine_v1_session_destroy(self.session); + miso_engine_v1_plan_destroy(self.plan); + } + } +} + +/// The empty mixer boots at every launch rate and renders silence without allocating; its status, +/// control list, counters and snapshot agree. +/// +/// Test value: red if any boot-time size that depends on the session's strip, source or meter +/// count fails at zero (a refused compile, a zero-length allocation the budget row cannot report, +/// a render that reads a first strip that is not there), if the silence is anything but `+0.0`, or +/// if one view of the mixer (resource row, watermark, control list, counters, snapshot) describes +/// a strip the others do not. +#[test] +fn an_empty_session_boots_and_renders_silence_without_allocating() { + assert_installed(); + for rate in RATES { + let model = empty_model(rate); + let mut host = Endpoint::boot(&model); + + let resources = host.resources(); + assert_eq!( + ( + resources.sample_rate_hz, + resources.quantum_frames, + resources.source_count, + resources.track_count, + ), + (rate, QUANTUM, 0, 0), + "{rate} Hz: the plan is empty" + ); + let watermark = host.watermark(); + assert_eq!( + watermark.revision, model.revision, + "{rate} Hz: boot watermark" + ); + + for block in 0..4 { + let (bits, allocations, deallocations) = host.render_counted(block); + assert!( + bits.iter().all(|word| *word == 0), + "{rate} Hz: block {block} is +0.0 silence" + ); + assert_eq!( + (allocations, deallocations), + (0, 0), + "{rate} Hz: block {block} allocates nothing" + ); + } + + // The control list names the console slot's session-level rows and no strip. + let (epoch, rows) = host.control_list(); + assert!( + rows.first() + .is_some_and(|row| row.contains("owner_kind: Session")) + && rows.iter().all(|row| !row.contains("owner_kind: Strip")), + "{rate} Hz: only the session owns rows: {rows:?}" + ); + // The render-peak meter of an empty mixer reads silence: configure it, render, and read + // the batch the service step stages (the service step arms the peak scan before the blocks). + let meter = protocol::TelemetryConfiguration { + meter_handles: vec![1], + meter_period_blocks: 1, + counter_ids: Vec::new(), + counter_period_blocks: 0, + diagnostics_enabled: false, + minimum_diagnostic_severity: protocol::DiagnosticSeverity::Info, + }; + let reply = host.send( + CommandPayload::TelemetryConfigure(&meter), + ExpectedRevision::Exact(SessionRevision(model.revision)), + ); + let mut fields = [0_u16; 64]; + let DecodedTypedResponseFrame::Success { header, .. } = ProtocolCodec::default() + .decode_typed_response(&reply, &mut DecodeScratch::new(&mut fields)) + .expect("telemetry reply") + else { + panic!("{rate} Hz: the meter configuration is refused") + }; + assert_eq!(header.status, StatusCode::Ok); + // SAFETY: The session is live and used by this thread alone. + assert_eq!(unsafe { miso_engine_v1_service(host.session) }, RESULT_OK); + for block in 4..6 { + let _ = host.render(block); + } + let peaks = host.meter_peaks(); + assert!( + !peaks.is_empty() && peaks.iter().all(|peak| *peak == 0.0), + "{rate} Hz: the meter reads silence: {peaks:?}" + ); + let (again_epoch, again_rows) = host.control_list(); + assert_eq!((epoch, &rows), (again_epoch, &again_rows)); + + assert_eq!( + host.counter(CounterId::LiveValuesSuperseded), + 0, + "{rate} Hz: no live value was superseded" + ); + let (revision, json) = host.snapshot(); + assert_eq!(revision, model.revision); + let snapshot = session::parse_session_json(&json).expect("the snapshot parses"); + assert!(snapshot.sources.is_empty() && snapshot.tracks.is_empty()); + assert_eq!(snapshot.console, model.console); + } +} + +/// One structural transaction adds a stereo stem, its track and its route to the booted empty +/// mixer: it takes the rebuild path, the swap block allocates nothing, the control list grows, +/// and the fed stem is heard. The browser mirrors this as a parity row. +/// +/// Test value: red if the first strip cannot arrive through the successor paths (a ring, a bank, +/// a meter or a staging size fixed at zero by the empty boot), if the swap block allocates or +/// frees, or if the stem is silent after the swap. +#[test] +fn a_structural_transaction_adds_the_first_stem_to_an_empty_session() { + assert_installed(); + for rate in RATES { + let model = empty_model(rate); + let mut host = Endpoint::boot(&model); + let (empty_epoch, empty_rows) = host.control_list(); + for block in 0..2 { + let _ = host.render(block); + } + + let edits = stem_edits(rate); + let reply = host.send( + CommandPayload::SessionTransactionApply(&edits), + ExpectedRevision::Exact(SessionRevision(host.revision)), + ); + let mut fields = [0_u16; 64]; + let DecodedTypedResponseFrame::Success { + header, + payload: DecodedSuccessResponsePayload::SessionTransactionApplied(applied), + } = ProtocolCodec::default() + .decode_typed_response(&reply, &mut DecodeScratch::new(&mut fields)) + .expect("transaction reply") + else { + panic!("{rate} Hz: the transaction is refused") + }; + assert_eq!(header.status, StatusCode::Ok); + assert_eq!(applied.edit_path, EditPath::Rebuild, "{rate} Hz"); + assert_eq!(header.revision, SessionRevision(model.revision + 1)); + + // The swap block: the successor adopts at the next block boundary, without allocating. + let (_, allocations, deallocations) = host.render_counted(2); + assert_eq!( + (allocations, deallocations), + (0, 0), + "{rate} Hz: swap block" + ); + let resources = host.resources(); + assert_eq!( + (resources.source_count, resources.track_count), + (1, 1), + "{rate} Hz: the plan holds the stem" + ); + assert_eq!(host.watermark().revision, model.revision + 1); + + let (epoch, rows) = host.control_list(); + assert!(epoch > empty_epoch, "{rate} Hz: the structure epoch moved"); + assert!( + rows.len() > empty_rows.len() && rows.iter().any(|row| row.contains("t1")), + "{rate} Hz: the strip's rows are listed" + ); + + // The source enters at the render clock the successor adopted at: a seek to a new + // generation, then that generation's chunks. + // SAFETY: The session is live and used by this thread alone; the name outlives the call. + let code = unsafe { miso_engine_v1_source_seek(host.session, b"stem".as_ptr(), 4, 2, 0) }; + assert_eq!(code, RESULT_OK, "{rate} Hz: seek"); + let mut heard = false; + for block in 3..11 { + host.feed(2, block - 3, rate); + let (bits, allocations, deallocations) = host.render_counted(block); + assert_eq!( + (allocations, deallocations), + (0, 0), + "{rate} Hz: block {block} allocates nothing" + ); + heard |= bits.iter().any(|word| f32::from_bits(*word) != 0.0); + } + assert!(heard, "{rate} Hz: the stem is heard"); + + // The last strip leaves by the same path: the mixer is empty again and silent. + let removal = [ + SessionEdit::RemoveRoute { + route_id: id("t1-main"), + }, + SessionEdit::RemoveTrack { track_id: id("t1") }, + SessionEdit::RemoveSource { + source_id: id("stem"), + }, + ]; + let reply = host.send( + CommandPayload::SessionTransactionApply(&removal), + ExpectedRevision::Exact(SessionRevision(model.revision + 1)), + ); + let DecodedTypedResponseFrame::Success { header, .. } = ProtocolCodec::default() + .decode_typed_response(&reply, &mut DecodeScratch::new(&mut fields)) + .expect("removal reply") + else { + panic!("{rate} Hz: removing the last strip is refused") + }; + assert_eq!(header.status, StatusCode::Ok); + for block in 11..14 { + let (bits, allocations, deallocations) = host.render_counted(block); + assert_eq!( + (allocations, deallocations), + (0, 0), + "{rate} Hz: block {block}" + ); + if block > 11 { + assert!( + bits.iter().all(|word| *word == 0), + "{rate} Hz: block {block} is silent again" + ); + } + } + let resources = host.resources(); + assert_eq!((resources.source_count, resources.track_count), (0, 0)); + } +} diff --git a/crates/capi/tests/resource_lifecycle.rs b/crates/capi/tests/resource_lifecycle.rs index 4cc5cc44e..45f3ce492 100644 --- a/crates/capi/tests/resource_lifecycle.rs +++ b/crates/capi/tests/resource_lifecycle.rs @@ -541,7 +541,6 @@ fn host_caps(limits: &CompileLimits) -> host_core::HostPrepareCaps { host_core::HostPrepareCaps { shape: host_core::HostShapePolicy::AnyLaunchRate, source_ring_frames: limits.source_ring_frames, - maximum_source_channels: None, maximum_automation_spans_per_block: limits.maximum_automation_spans_per_block, maximum_tracks: limits.maximum_tracks, maximum_submixes: if limits.maximum_submixes == 0 { diff --git a/crates/control-plane/src/compile.rs b/crates/control-plane/src/compile.rs index ef21b1103..e792b75df 100644 --- a/crates/control-plane/src/compile.rs +++ b/crates/control-plane/src/compile.rs @@ -787,7 +787,7 @@ pub fn validate_live_peak( /// /// This is the only place the mapping is spelled. `AnyLaunchRate`: the C ABI compiles whatever /// launch rate the session declares (issue 032), unlike the browser host which is pinned to its -/// `AudioContext`. `maximum_source_channels: None`: the C ABI has no such limit field. +/// `AudioContext`. /// /// Public only to the adapters' tests, under `test-support`; crate-private otherwise. #[cfg_attr(not(feature = "test-support"), allow(unreachable_pub))] @@ -795,7 +795,6 @@ pub fn prepare_caps(limits: ControlLimits) -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: limits.source_ring_frames, - maximum_source_channels: None, maximum_automation_spans_per_block: limits.maximum_automation_spans_per_block, maximum_tracks: limits.maximum_tracks, // #1206 D2: zero is every pre-#1206 caller's value, and means "bound submixes by tracks". diff --git a/crates/host-core/src/limiter_linked_session.rs b/crates/host-core/src/limiter_linked_session.rs index 5bca5f62d..70e265da8 100644 --- a/crates/host-core/src/limiter_linked_session.rs +++ b/crates/host-core/src/limiter_linked_session.rs @@ -258,7 +258,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 1_024, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 256, maximum_submixes: 256, diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index a58afd983..26ead26af 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -136,8 +136,6 @@ pub struct HostPrepareCaps { pub shape: HostShapePolicy, /// Per-source ring capacity in frames; must be a nonzero multiple of the session quantum. pub source_ring_frames: u32, - /// Optional per-source channel-count cap. `None` accepts whatever the session declares. - pub maximum_source_channels: Option, /// Automation spans the effect compiler may schedule in one block. pub maximum_automation_spans_per_block: u32, /// Maximum tracks in the session. @@ -1939,12 +1937,6 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( let mut carried_overhead_bytes = 0_u64; let mut carried_largest_bytes = 0_u64; for (index, source) in model.sources.iter().enumerate() { - if caps - .maximum_source_channels - .is_some_and(|maximum| u32::from(source.channels) > maximum) - { - return Err(shape("host.source.channels")); - } let index = u32::try_from(index).map_err(|_| platform("host.count"))?; let ring = PcmSourceRingConfig { channel_count: u32::from(source.channels), diff --git a/crates/host-core/src/response.rs b/crates/host-core/src/response.rs index 1a97c3f96..52fb825d1 100644 --- a/crates/host-core/src/response.rs +++ b/crates/host-core/src/response.rs @@ -1389,7 +1389,6 @@ mod tests { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 256, - maximum_source_channels: None, maximum_automation_spans_per_block: 4096, maximum_tracks: u64::MAX, maximum_submixes: u64::MAX, diff --git a/crates/host-core/tests/MUTATIONS.md b/crates/host-core/tests/MUTATIONS.md index 97b28f1a5..1de7892fb 100644 --- a/crates/host-core/tests/MUTATIONS.md +++ b/crates/host-core/tests/MUTATIONS.md @@ -67,11 +67,6 @@ the two sides are independent witnesses. M-04 is the same mutation against the f * Red: `shape_policy_pins_rate_and_quantum` — a session with quantum 128 is accepted under a policy demanding 64. -### M-06 — the per-source channel cap is ignored -* Mutation: delete the `caps.maximum_source_channels` check in `prepare_host_runtime`. -* Command: `cargo test -p host-core --test prepare` -* Red: `source_channel_cap_is_optional` — a stereo source is admitted under `Some(1)`. - ### M-07 — the named-allocation cap rejects its own reported row * Mutation: `largest_engine_allocation_bytes.max(..) > caps.maximum_named_allocation_bytes` → `>=`. * Command: `cargo test -p host-core --test prepare` diff --git a/crates/host-core/tests/collapse_arming.rs b/crates/host-core/tests/collapse_arming.rs index c5de8a38d..32e1edef2 100644 --- a/crates/host-core/tests/collapse_arming.rs +++ b/crates/host-core/tests/collapse_arming.rs @@ -55,7 +55,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/effect_live_controls.rs b/crates/host-core/tests/effect_live_controls.rs index 94840e4a3..e41e04fba 100644 --- a/crates/host-core/tests/effect_live_controls.rs +++ b/crates/host-core/tests/effect_live_controls.rs @@ -31,7 +31,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 1_024, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/effect_observation.rs b/crates/host-core/tests/effect_observation.rs index 7dba54952..09b00aa85 100644 --- a/crates/host-core/tests/effect_observation.rs +++ b/crates/host-core/tests/effect_observation.rs @@ -42,7 +42,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 1_024, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/empty_boot.rs b/crates/host-core/tests/empty_boot.rs new file mode 100644 index 000000000..471b010ee --- /dev/null +++ b/crates/host-core/tests/empty_boot.rs @@ -0,0 +1,147 @@ +//! TBD30 D1 and D2: a session with no source and no track prepares through the shared facade and +//! renders silence, with no special path for a host adapter. +//! +//! The browser and the C ABI both prepare through `prepare_host_runtime`, so this is the one core +//! gate of the empty boot; `crates/capi/tests/empty_boot.rs` drives the exported entries. + +use bench_support::alloc as bench_alloc; +use engine::realtime::{PlanarBufferMut, RenderIo}; +use host_core::{HostPrepareCaps, HostShapePolicy, prepare_host_session}; +use session::{Route, RouteDestination, RouteSource, SendTap, SessionModel, StableId, Submix, Vca}; + +const FIXTURE: &str = include_str!("../../../fixtures/session/v1/parametric-eq-nine-track.json"); +const QUANTUM: usize = 128; + +fn caps() -> HostPrepareCaps { + HostPrepareCaps { + shape: HostShapePolicy::AnyLaunchRate, + source_ring_frames: 1_024, + maximum_automation_spans_per_block: 128, + maximum_tracks: 100, + maximum_submixes: 100, + maximum_vcas: 100, + maximum_vca_reach_pairs: u64::MAX, + maximum_sources: 100, + maximum_routes: 100, + maximum_effects: 100, + maximum_graph_session_plus_plan_bytes: 100_000_000, + maximum_source_total_bytes: 10_000_000, + maximum_source_overhead_bytes: 10_000_000, + maximum_effect_state_bytes: 100_000_000, + maximum_effect_scratch_bytes: 100_000_000, + maximum_builtin_retained_bytes: 100_000_000, + maximum_named_allocation_bytes: 100_000_000, + maximum_meter_streams: 1, + maximum_meter_items: 1, + maximum_meter_bytes: 1, + } +} + +fn id(text: &str) -> StableId { + StableId::parse(text).expect("stable ID") +} + +/// The fixture with no source, no track and no route; its console slot stays declared. +fn empty_model() -> SessionModel { + let mut model = session::parse_session_json(FIXTURE).expect("fixture"); + model.sources.clear(); + model.tracks.clear(); + model.routes.clear(); + model +} + +/// The empty model with one submix routed into the output and one VCA that holds the submix. +fn empty_model_with_a_bus() -> SessionModel { + let mut model = empty_model(); + let submix = Submix::unity(id("bus"), &model.console); + model.submixes.push(submix); + model.routes.push(Route { + id: id("bus-main"), + source: RouteSource::Submix { + submix_id: id("bus"), + tap: SendTap::PostPan, + }, + destination: RouteDestination::OutputInput { + output_id: id("main-out"), + }, + channel_matrix: session::ChannelMatrix { + ll: 1.0, + lr: 0.0, + rl: 0.0, + rr: 1.0, + }, + gain_db: 0.0, + mute: false, + follows_mute: false, + }); + model.vcas.push(Vca { + id: id("master"), + fader: session::DualMonoFader { + left_db: -3.0, + right_db: -3.0, + left_mute: false, + right_mute: false, + }, + members: vec![id("bus")], + }); + model +} + +/// Prepares `model`, checks the report's counts and renders four blocks of `+0.0`, counting the +/// render thread's allocator calls. +fn boots_silent(model: &SessionModel, label: &str) { + let document = session::canonical_session_json(model).expect("canonical session"); + let (_compiled, mut prepared) = + prepare_host_session(&document, &caps()).unwrap_or_else(|failure| { + panic!("{label}: {}", String::from_utf8_lossy(failure.as_bytes())) + }); + assert_eq!( + ( + prepared.report.source_count, + prepared.report.track_count, + prepared.sources.len() + ), + (0, 0, 0), + "{label}: nothing to feed" + ); + bench_alloc::assert_installed(); + for block in 0..4 { + let mut samples = [f32::NAN; QUANTUM * 2]; + let output = + PlanarBufferMut::try_new(&mut samples, 2, QUANTUM, QUANTUM).expect("output planes"); + let mark = bench_alloc::current_thread_counters(); + prepared + .plan + .render_contiguous(RenderIo { output }, (block * QUANTUM) as u64) + .expect("render"); + let moved = bench_alloc::current_thread_delta_since(mark); + assert_eq!( + (moved.allocations, moved.deallocations), + (0, 0), + "{label}: block {block} allocates nothing" + ); + assert!( + samples.iter().all(|sample| sample.to_bits() == 0), + "{label}: block {block} is +0.0 silence" + ); + } +} + +/// Test value: red if the shared preparation (the source set, the graph, the buses or the console +/// slots) refuses or mis-sizes a session whose strip count is zero, or renders anything but +/// silence for it. The source graph set refused an empty source list before this slice, so every +/// host failed to boot an empty mixer. +#[test] +fn an_empty_session_prepares_and_renders_silence() { + boots_silent(&empty_model(), "console slot only"); + let minimal = include_str!("../../../fixtures/session/v1/canonical-minimal.json"); + let model = session::parse_session_json(minimal).expect("minimal fixture"); + boots_silent(&model, "minimal"); +} + +/// Test value: red if declaring a submix and a VCA (no track feeds the bus) breaks the empty boot: +/// a bus with no input renders silence, and a VCA over it carries no audio. +#[test] +fn declared_submixes_and_vcas_do_not_make_an_empty_session_noisy() { + boots_silent(&empty_model_with_a_bus(), "an unfed bus"); +} diff --git a/crates/host-core/tests/fp_environment.rs b/crates/host-core/tests/fp_environment.rs index a0d7a8d17..78e7c6c96 100644 --- a/crates/host-core/tests/fp_environment.rs +++ b/crates/host-core/tests/fp_environment.rs @@ -34,7 +34,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 1_024, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/input_liveness_live_controls.rs b/crates/host-core/tests/input_liveness_live_controls.rs index 7f0f618be..30b69c166 100644 --- a/crates/host-core/tests/input_liveness_live_controls.rs +++ b/crates/host-core/tests/input_liveness_live_controls.rs @@ -70,7 +70,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 1_024, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/launch_registry_once.rs b/crates/host-core/tests/launch_registry_once.rs index 6963576da..c1f017329 100644 --- a/crates/host-core/tests/launch_registry_once.rs +++ b/crates/host-core/tests/launch_registry_once.rs @@ -41,7 +41,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/live_addressing.rs b/crates/host-core/tests/live_addressing.rs index 0bd20f22e..7a352cd2d 100644 --- a/crates/host-core/tests/live_addressing.rs +++ b/crates/host-core/tests/live_addressing.rs @@ -58,7 +58,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 1_024, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index 2d69f2e0e..6f21f28da 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -52,7 +52,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/live_lanes.rs b/crates/host-core/tests/live_lanes.rs index 536f2c8be..f3783d73f 100644 --- a/crates/host-core/tests/live_lanes.rs +++ b/crates/host-core/tests/live_lanes.rs @@ -45,7 +45,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/live_routes.rs b/crates/host-core/tests/live_routes.rs index 771fc6901..82b0b5744 100644 --- a/crates/host-core/tests/live_routes.rs +++ b/crates/host-core/tests/live_routes.rs @@ -47,7 +47,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/live_transaction.rs b/crates/host-core/tests/live_transaction.rs index c637a0484..a42063d84 100644 --- a/crates/host-core/tests/live_transaction.rs +++ b/crates/host-core/tests/live_transaction.rs @@ -33,7 +33,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/monitor_tick.rs b/crates/host-core/tests/monitor_tick.rs index 4aa62c2c8..f5fc7ae0c 100644 --- a/crates/host-core/tests/monitor_tick.rs +++ b/crates/host-core/tests/monitor_tick.rs @@ -43,7 +43,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/prepare.rs b/crates/host-core/tests/prepare.rs index 8dccea83f..10f71de87 100644 --- a/crates/host-core/tests/prepare.rs +++ b/crates/host-core/tests/prepare.rs @@ -24,7 +24,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 1_024, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, @@ -652,27 +651,6 @@ fn default_ring_derivation_covers_the_stall_and_two_in_flight_quanta() { assert_eq!(default_source_ring_frames(96_000, 127), 9_906); } -/// A per-source channel cap is a host policy, not a session rule: the C ABI host does not set one -/// and the browser host does. -#[test] -fn source_channel_cap_is_optional() { - let capped = HostPrepareCaps { - maximum_source_channels: Some(1), - ..caps() - }; - let failure = prepare_host_session(SESSION, &capped) - .map(|_| ()) - .expect_err("stereo source over the cap"); - assert_eq!(failure.kind(), PrepareRejection::Shape); - assert_eq!(failure.as_bytes(), b"host.source.channels\t$\n"); - - let permitted = HostPrepareCaps { - maximum_source_channels: Some(2), - ..caps() - }; - prepare_host_session(SESSION, &permitted).expect("stereo source within the cap"); -} - /// The `Send + !Sync` half of the crate-level `# Host callback contract (V1)`. /// /// A host that could share these across threads could render from two threads at once, which the diff --git a/crates/host-core/tests/randomized.rs b/crates/host-core/tests/randomized.rs index c8b5d7c11..83dbf79f4 100644 --- a/crates/host-core/tests/randomized.rs +++ b/crates/host-core/tests/randomized.rs @@ -92,7 +92,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/route_mute.rs b/crates/host-core/tests/route_mute.rs index 8033c8f03..25a1c5699 100644 --- a/crates/host-core/tests/route_mute.rs +++ b/crates/host-core/tests/route_mute.rs @@ -58,7 +58,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/source_in_place.rs b/crates/host-core/tests/source_in_place.rs index ad546a7c9..4a6bfb569 100644 --- a/crates/host-core/tests/source_in_place.rs +++ b/crates/host-core/tests/source_in_place.rs @@ -25,7 +25,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 1_024, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/spectrum.rs b/crates/host-core/tests/spectrum.rs index 3657fc81a..2641be9ed 100644 --- a/crates/host-core/tests/spectrum.rs +++ b/crates/host-core/tests/spectrum.rs @@ -19,7 +19,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 1_024, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/spectrum_service.rs b/crates/host-core/tests/spectrum_service.rs index c41d57dd5..59bb91a0b 100644 --- a/crates/host-core/tests/spectrum_service.rs +++ b/crates/host-core/tests/spectrum_service.rs @@ -25,7 +25,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 1_024, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/stored_automation.rs b/crates/host-core/tests/stored_automation.rs index 44f6cd662..c445e552d 100644 --- a/crates/host-core/tests/stored_automation.rs +++ b/crates/host-core/tests/stored_automation.rs @@ -14,7 +14,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/strip_controls.rs b/crates/host-core/tests/strip_controls.rs index 9faa55c34..ab54ec252 100644 --- a/crates/host-core/tests/strip_controls.rs +++ b/crates/host-core/tests/strip_controls.rs @@ -31,7 +31,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/strip_handles.rs b/crates/host-core/tests/strip_handles.rs index 2caa8d508..6873e5939 100644 --- a/crates/host-core/tests/strip_handles.rs +++ b/crates/host-core/tests/strip_handles.rs @@ -25,7 +25,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 1_024, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/strip_meters.rs b/crates/host-core/tests/strip_meters.rs index a16f17cf3..3c8f26759 100644 --- a/crates/host-core/tests/strip_meters.rs +++ b/crates/host-core/tests/strip_meters.rs @@ -54,7 +54,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/submix_caps.rs b/crates/host-core/tests/submix_caps.rs index fbe7c7b15..faa43bf1b 100644 --- a/crates/host-core/tests/submix_caps.rs +++ b/crates/host-core/tests/submix_caps.rs @@ -16,7 +16,6 @@ fn caps(maximum_submixes: u64) -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 1_024, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: TRACKS, maximum_submixes, diff --git a/crates/host-core/tests/submix_strip.rs b/crates/host-core/tests/submix_strip.rs index 0edbeb01d..430342a46 100644 --- a/crates/host-core/tests/submix_strip.rs +++ b/crates/host-core/tests/submix_strip.rs @@ -73,7 +73,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/support/successor.rs b/crates/host-core/tests/support/successor.rs index eab3cb6a0..cad230f53 100644 --- a/crates/host-core/tests/support/successor.rs +++ b/crates/host-core/tests/support/successor.rs @@ -35,7 +35,6 @@ pub(crate) fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/symmetry_witness.rs b/crates/host-core/tests/symmetry_witness.rs index 26d033dd2..fb8e70286 100644 --- a/crates/host-core/tests/symmetry_witness.rs +++ b/crates/host-core/tests/symmetry_witness.rs @@ -71,7 +71,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 1_024, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/track_delay.rs b/crates/host-core/tests/track_delay.rs index 8f52c4cb3..37d2f29da 100644 --- a/crates/host-core/tests/track_delay.rs +++ b/crates/host-core/tests/track_delay.rs @@ -41,7 +41,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/vca.rs b/crates/host-core/tests/vca.rs index fba00fcbe..51c1231d9 100644 --- a/crates/host-core/tests/vca.rs +++ b/crates/host-core/tests/vca.rs @@ -39,7 +39,6 @@ fn caps() -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 4_096, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: 100, maximum_submixes: 100, diff --git a/crates/host-core/tests/vca_caps.rs b/crates/host-core/tests/vca_caps.rs index ce10fae77..2e5d3b7a5 100644 --- a/crates/host-core/tests/vca_caps.rs +++ b/crates/host-core/tests/vca_caps.rs @@ -19,7 +19,6 @@ fn caps(maximum_vcas: u64) -> HostPrepareCaps { HostPrepareCaps { shape: HostShapePolicy::AnyLaunchRate, source_ring_frames: 1_024, - maximum_source_channels: None, maximum_automation_spans_per_block: 128, maximum_tracks: TRACKS, maximum_submixes: 0, diff --git a/crates/source/src/lib.rs b/crates/source/src/lib.rs index 4acd1b8e8..f6c477082 100644 --- a/crates/source/src/lib.rs +++ b/crates/source/src/lib.rs @@ -1647,7 +1647,6 @@ pub struct SourceGraphTrackMapping { /// Rejection while sealing source consumers and mappings for graph fan-out. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum SourceGraphSourceSetError { - EmptySources, SourceIndex, ChannelIndex, ArithmeticOverflow, @@ -1950,9 +1949,6 @@ pub fn prepare_graph_source_set( sources: Vec, mappings: Vec, ) -> Result { - if sources.is_empty() { - return Err(SourceGraphSourceSetError::EmptySources); - } let retained = source_set_retained_resources(&sources, &mappings)?; let mut pcm_payload = 0_u64; let mut overhead = 0_u64; diff --git a/docs/REALTIME_DEPENDENCY_POLICY.md b/docs/REALTIME_DEPENDENCY_POLICY.md index b70f23ba5..e23eb2c10 100644 --- a/docs/REALTIME_DEPENDENCY_POLICY.md +++ b/docs/REALTIME_DEPENDENCY_POLICY.md @@ -203,6 +203,12 @@ no reserved (or `RES0`) bit, and names the guard or write-back that restores the C calls themselves (engine create and destroy, compile, command submit, session and plan destroy), each with the handle and storage contract stated beside it; it installs no allocator and calls no render entry. +- `crates/capi/tests/empty_boot.rs` (TBD30): the exported-C evidence that a session with no source + and no track boots, renders silence and grows by a structural transaction, with zero allocator + calls in the render entry. Its unsafe is the C calls themselves (engine create and destroy, + compile, command submit, service, event dequeue, source seek and submit, plan resource and + watermark queries, render, session and plan destroy), each with the handle and storage contract + stated beside it; the allocator it runs under is `bench_support::alloc`'s. - `crates/capi/tests/resource_lifecycle.rs` (issue 119): the exported-C retained-allocation and disposal evidence. It calls the C entries, including the render entry, and owns `LifecycleAllocator`, a `GlobalAlloc` that delegates the original pointer and layout unchanged @@ -377,7 +383,7 @@ Approved unsafe code that runs on a render thread, per entry: `dispose` and asserts it is zero, so a boot-time allocation inside a window fails it as a render-time one does. - Every other entry is test or tool code. `plan_swap_race.rs`, `resource_lifecycle.rs`, - `input_design_bounds.rs`, `control_parity.rs` and `tools/audit/src/capi.rs` call the C + `input_design_bounds.rs`, `empty_boot.rs`, `control_parity.rs` and `tools/audit/src/capi.rs` call the C render entry from threads of their own. Their own unsafe is the caller side of the C contract, apart from `resource_lifecycle.rs`'s `LifecycleAllocator`, which runs on the same test thread as its render calls. diff --git a/hosts/host-web/src/lib.rs b/hosts/host-web/src/lib.rs index c266e0153..8a1c932a6 100644 --- a/hosts/host-web/src/lib.rs +++ b/hosts/host-web/src/lib.rs @@ -4986,7 +4986,6 @@ fn prepare_caps( quantum_frames, }, source_ring_frames, - maximum_source_channels: None, maximum_automation_spans_per_block: automation_spans, maximum_tracks: u64::MAX, maximum_submixes: u64::MAX, diff --git a/tools/realtime-policy/src/policy.rs b/tools/realtime-policy/src/policy.rs index 0698bd166..0b0c372f8 100644 --- a/tools/realtime-policy/src/policy.rs +++ b/tools/realtime-policy/src/policy.rs @@ -179,7 +179,7 @@ impl Policy { pub fn workspace() -> Self { // The unsafe-ownership files. `docs/REALTIME_DEPENDENCY_POLICY.md`, "Unsafe-code ownership", // carries the justification of each. - const UNSAFE_ALLOWLIST: [&str; 23] = [ + const UNSAFE_ALLOWLIST: [&str; 24] = [ "crates/engine/src/realtime/spsc.rs", "crates/engine/src/realtime/disjoint.rs", "crates/lane/src/softfma.rs", @@ -196,6 +196,7 @@ impl Policy { "crates/capi/tests/input_design_bounds.rs", "crates/capi/tests/plan_swap_race.rs", "crates/capi/tests/response_get.rs", + "crates/capi/tests/empty_boot.rs", "hosts/host-web/src/ffi.rs", "hosts/host-web/src/render_lock.rs", "hosts/host-web/tests/boot_transient_budget.rs", From f6191808476a61658908caac9f4371f9ad8cee63 Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 13:46:30 +0000 Subject: [PATCH 06/12] Run the automation row rule when a row's activity changes (#1631) controls::activity_changed holds when the strip set or a strip's pan/matrix form changes; the classifier then runs the stored-automation row rule, so a live switch that would strand an automation entry is refused control.inactive before any commit on both hosts (parity test), and a switch that strands nothing stays live. Co-Authored-By: Claude Opus 5.5 --- ...n-row-rule-when-a-rows-activity-changes.md | 29 ++++ crates/capi/src/runtime/live_tests.rs | 77 +++++++++++ crates/controls/src/diff.rs | 29 ++++ crates/controls/src/lib.rs | 4 +- crates/host-core/src/live_delta.rs | 10 +- crates/host-core/tests/live_delta.rs | 101 ++++++++++++++ hosts/host-web/tests/control_parity.rs | 127 +++++++++++++++++- 7 files changed, 370 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_SPECS/1631-run-the-automation-row-rule-when-a-rows-activity-changes.md b/.github/ISSUE_SPECS/1631-run-the-automation-row-rule-when-a-rows-activity-changes.md index bd4daeee9..f88098c07 100644 --- a/.github/ISSUE_SPECS/1631-run-the-automation-row-rule-when-a-rows-activity-changes.md +++ b/.github/ISSUE_SPECS/1631-run-the-automation-row-rule-when-a-rows-activity-changes.md @@ -70,3 +70,32 @@ transaction also retargets or removes the entry. Found by reading, not reproduce ## Size Half a day. + +## Decision record: #1631 (batch 8) + +Shipped. Anchors moved: step 2 sits at `crates/host-core/src/live_delta.rs` ~344 (the spec said +371-376); the rule is otherwise as filed. + +- `controls::activity_changed(current, next)` (`crates/controls/src/diff.rs`, re-exported from + `lib.rs`): true when the two models do not list the same strips in the same order, or when a strip's + `matrix_or_pan` variant differs. A later value-dependent row joins it there. +- Classifier step 2 runs `automation_diagnostics(next, ..)` when the automation table changes or + `activity_changed` holds. A refusal is `LiveRebuild::AutomationTarget`; the rebuild's preparation + refuses it `control.inactive` at `$.automation[id=].target` before any commit. +- Departure (test shape): the browser parity gate is a separate test, not a script row. A rebuild + route refusal is a non-ok entry result with the diagnostic in the last-error buffer on both hosts + (browser 10, C ABI 5: each host numbers its results), not a typed verdict frame, and the script + harness asserts an ok result. `CAbi::send_status` and `CAbi::last_error` were added to + `control_parity.rs` for it (the diagnostic is read before the reliable-lane drain, which clears it). + +Gates and tests: +- Gate 1 and 3 (classifier): `host-core/tests/live_delta.rs::a_mode_switch_that_strands_an_automation_needs_a_rebuild` + (pan entry then matrix switch, and the reverse, give `AutomationTarget`; another strip's entry and a + strip with only a fader entry stay live). +- Gate 1 and 2 (C ABI): `capi/src/runtime/live_tests.rs::set_control::a_mode_switch_under_a_stored_pan_entry_is_refused_before_any_commit` + (refused, diagnostic names `control.inactive` and the path, state unchanged; switch plus + `SetAutomationTarget` commits; an entry-free strip switches live). +- Gate 1 and 2 (parity): `hosts/host-web/tests/control_parity.rs::a_mode_switch_under_a_stored_pan_entry_is_refused_alike_on_both_hosts` + at all four rates (same diagnostic bytes; the same expected revision then commits the switch with + the retarget, `model_only` because the switch keeps the effective 2x2). +- Red on revert: all three fail with step 2 restored to the table-only condition. diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 1c7962161..89a65fa8a 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -7855,6 +7855,83 @@ mod set_control { assert_eq!(bits(pcm), bits(&prepared[block]), "rebuild, block {block}"); } } + + /// #1631. A `matrix_mode` switch under a stored pan entry is refused with the row rule's + /// `control.inactive` at the entry's path, before any commit: the revision, the committed + /// document, the replay log and the reliable lane do not move and no candidate is prepared. The + /// switch together with the entry's retarget to the matrix row commits, and a switch on a strip + /// with no pan or matrix entry stays live. + /// + /// Red mutation: run the classifier's row rule only when the automation table changes -> the + /// switch commits live and the model holds an entry that its own next rebuild refuses. + #[test] + fn a_mode_switch_under_a_stored_pan_entry_is_refused_before_any_commit() { + let document = effect_session(10, 48_000); + let mut rig = Rig::new(&document); + rig.step(); + let ride = SessionEdit::UpsertAutomation { + automation: session::Automation { + id: id("pan-ride"), + target: builtin("eq0", PAN, Lane::Left), + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: 960, + start_value: 0.0, + end_value: 0.5, + unit: session::ParameterUnit::Linear, + }], + }, + }; + assert_eq!( + rig.apply(&[ride]), + crate::RESULT_OK, + "a pan entry on a pan strip" + ); + + let switch = set(own("eq0", MATRIX_MODE, Lane::Both), 1.0, None); + let before = (refusal_state(&rig), rig.summary()); + assert_eq!( + rig.apply(core::slice::from_ref(&switch)), + crate::RESULT_COMPILE_REJECTED + ); + let diagnostics = String::from_utf8_lossy(&rig.last_error()).into_owned(); + assert!( + diagnostics.contains("control.inactive") + && diagnostics.contains("$.automation[id=pan-ride].target"), + "{diagnostics}" + ); + assert_eq!( + (refusal_state(&rig), rig.summary()), + before, + "nothing committed, nothing prepared" + ); + + let retarget = SessionEdit::SetAutomationTarget { + automation_id: id("pan-ride"), + target: builtin("eq0", MATRIX_LL, Lane::Both), + }; + let revision = rig.summary().0; + assert_eq!( + rig.apply(&[switch, retarget]), + crate::RESULT_OK, + "the switch with the entry retargeted" + ); + let after = rig.summary(); + assert_eq!( + (after.0, after.3), + (revision + 1, 0), + "one revision, live, no candidate" + ); + + let other = set(own("eq1", MATRIX_MODE, Lane::Both), 1.0, None); + assert_eq!( + rig.apply(&[other]), + crate::RESULT_OK, + "no entry, no refusal" + ); + assert_eq!(rig.summary().3, 0, "still live"); + } } // --------------------------------------------------------------------------------------------- diff --git a/crates/controls/src/diff.rs b/crates/controls/src/diff.rs index 39d95b2a8..7f7c61a47 100644 --- a/crates/controls/src/diff.rs +++ b/crates/controls/src/diff.rs @@ -77,6 +77,35 @@ pub fn changed_controls( Some(changed) } +/// Whether any control row's `active` flag differs between `current` and `next` (#1631 D1). +/// +/// A row's activity depends on a value of the model, not on the row's own value: a strip's +/// `matrix_mode` decides which of its pan and matrix rows are active. Today the predicate is true +/// exactly when a strip's `matrix_or_pan` variant differs; a later row whose activity depends on a +/// value joins it here, so a caller keeps no row list. Two models that do not hold the same strips +/// in the same order differ in structure, which counts as a change. +/// +/// Allocation: none. +#[must_use] +pub fn activity_changed(current: &SessionModel, next: &SessionModel) -> bool { + let mut before = current.strips(); + let mut after = next.strips(); + loop { + match (before.next(), after.next()) { + (None, None) => return false, + (Some(old), Some(new)) => { + if old.id != new.id + || std::mem::discriminant(old.matrix_or_pan) + != std::mem::discriminant(new.matrix_or_pan) + { + return true; + } + } + _ => return true, + } + } +} + /// Whether a write to the row at `address` changes that row alone: the session's rows do not (a ramp /// length changes every owner's descriptors) and a strip's `matrix_mode` does not (the switch flips /// which rows are active and rewrites the pan or the matrix). A host that applies a transaction of diff --git a/crates/controls/src/lib.rs b/crates/controls/src/lib.rs index 83ee1b379..4ea34f1d2 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -46,7 +46,9 @@ mod view; pub use automation::{ControlDiagnostic, automation_diagnostics}; pub use descriptor::{Choice, ControlDescriptor, ControlScope, DomainRule, Lanes, Motion, check}; -pub use diff::{ChangedControl, changed_controls, changed_rows, writes_only_its_row}; +pub use diff::{ + ChangedControl, activity_changed, changed_controls, changed_rows, writes_only_its_row, +}; pub use effect_contract::control::{ ControlClass, ControlKind, Family, LiveRampRow, RebuildReason, Taper, }; diff --git a/crates/host-core/src/live_delta.rs b/crates/host-core/src/live_delta.rs index 490c0c1d4..5269535f0 100644 --- a/crates/host-core/src/live_delta.rs +++ b/crates/host-core/src/live_delta.rs @@ -219,9 +219,11 @@ pub use crate::prepare::LiveRebuild; /// [`controls::mask_values`] copied from `current`, has canonical JSON bytes other than /// `current`'s. Bytes, never `PartialEq`: the canonical `f32` spelling keeps a zero's sign. A /// canonical JSON error is structural too. -/// 2. [`LiveRebuild::AutomationTarget`] if `current.automation != next.automation` and -/// [`controls::automation_diagnostics`] refuses one of `next`'s automations (decision 15 E1, -/// #1335 D4, #1566 D6). An unchanged automation table is never re-checked: it was prepared. An accepted +/// 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 /// 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 @@ -341,7 +343,7 @@ pub fn classify_live_delta_with_monitor<'a>( drop(masked); // Step 2. - if current.automation != next.automation + if (current.automation != next.automation || controls::activity_changed(current, next)) && !controls::automation_diagnostics(next, next.sample_rate_hz).is_empty() { return Err(LiveRebuild::AutomationTarget); diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index 6f21f28da..fef9a86a2 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -1120,6 +1120,107 @@ fn fader_ride(model: &SessionModel) -> Automation { } } +/// A ride on the first track's pan (builtin 12, left lane) or, with `matrix`, on its `matrix_ll` +/// (builtin 7, a shared row): the rows `matrix_mode` flips between. +fn pan_ride(model: &SessionModel, matrix: bool) -> Automation { + let mut ride = fader_ride(model); + ride.id = id("pan-ride"); + ride.target.control = if matrix { 7 } else { 12 }; + ride.target.lane = if matrix { + ParameterChannel::Both + } else { + ParameterChannel::Left + }; + ride.segments[0].unit = ParameterUnit::Linear; + ride.segments[0].start_value = 0.0; + ride.segments[0].end_value = 0.5; + ride +} + +fn switch_to_matrix(next: &mut SessionModel) { + next.tracks[0].matrix_or_pan = MatrixOrPan::Matrix { + ll: 1.0, + lr: 0.0, + rl: 0.0, + rr: 1.0, + smoothing_samples: 0, + }; +} + +/// #1631 gate 1 and 2 and 3. A mode switch that leaves a stored pan entry on an inactive row is a +/// rebuild (the row rule refuses it `control.inactive` at preparation, before any commit); the +/// same switch with the entry retargeted to the matrix row, or on a strip without such an entry, +/// stays live. +/// +/// Red if step 2 runs only when the automation table changes: the switch is then live and the +/// committed model holds an entry that its next rebuild refuses. +#[test] +fn a_mode_switch_that_strands_an_automation_needs_a_rebuild() { + let mut declared = two_tracks(); + let ride = pan_ride(&declared, false); + declared.automation.push(ride); + let current = normalized(&declared); + assert!( + controls::automation_diagnostics(¤t, current.sample_rate_hz).is_empty(), + "the pan entry is accepted on a pan strip" + ); + + assert_eq!( + classify(¤t, switch_to_matrix), + Err(LiveRebuild::AutomationTarget), + "a switch under a pan entry" + ); + assert!( + classify(¤t, |next| { + switch_to_matrix(next); + next.automation[0] = pan_ride(next, true); + }) + .is_ok(), + "the switch with the entry retargeted to the matrix row" + ); + assert!( + classify(¤t, |next| { + switch_to_matrix(next); + next.automation.clear(); + }) + .is_ok(), + "the switch with the entry removed" + ); + + let mut plain = two_tracks(); + plain.automation.push(fader_ride(&plain)); + let plain = normalized(&plain); + assert!( + classify(&plain, switch_to_matrix).is_ok(), + "a strip with no pan or matrix entry switches live" + ); + let mut other = two_tracks(); + let mut ride = pan_ride(&other, false); + ride.target.owner = Owner::Strip(other.tracks[1].id.clone()); + other.automation.push(ride); + assert!( + classify(&normalized(&other), switch_to_matrix).is_ok(), + "an entry on another strip is not stranded" + ); + // The reverse: a matrix entry on a matrix strip, switched back to pan. + let mut matrix = two_tracks(); + switch_to_matrix(&mut matrix); + let ride = pan_ride(&matrix, true); + matrix.automation.push(ride); + let matrix = normalized(&matrix); + assert_eq!( + classify(&matrix, |next| { + next.tracks[0].matrix_or_pan = MatrixOrPan::Pan { + left: 0.0, + right: 0.0, + smoothing_samples: 0, + }; + }), + Err(LiveRebuild::AutomationTarget), + "a switch back to pan under a matrix entry" + ); +} + /// 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/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 9a3b9cd6f..d248f97df 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -68,6 +68,9 @@ const OUTER_VCA: VcaRef = VcaRef { id: "outer" }; const MATRIX_MODE_ROW: u32 = 1; /// The builtin row of a strip's `delay_samples`. const DELAY_SAMPLES_ROW: u32 = 11; +/// The builtin rows of a strip's `pan` and `matrix_ll`. +const PAN_ROW: u32 = 12; +const MATRIX_LL_ROW: u32 = 7; const RATES: [u32; 4] = [44_100, 48_000, 88_200, 96_000]; /// Which lane(s) of a dual-mono value an edit writes. @@ -1230,6 +1233,35 @@ impl CAbi { /// Send one frame and decode the answer. fn send(&mut self, payload: CommandPayload<'_>, expected: ExpectedRevision) -> Vec { + let (code, response) = self.send_status(payload, expected); + assert_eq!(code, C_RESULT_OK, "C ABI command"); + response + } + + /// The session's last diagnostic, through `miso_engine_v1_last_error`. + fn last_error(&self) -> Vec { + let mut storage = vec![0_u8; 4_096]; + let mut out = BytesOut { + struct_size: BYTES_OUT_SIZE, + reserved0: 0, + data: storage.as_mut_ptr(), + capacity_bytes: storage.len() as u64, + required_bytes: 0, + }; + // SAFETY: The session is live and used by this thread alone; the buffer is owned. + let code = unsafe { capi::miso_engine_v1_last_error(self.session.cast(), &mut out) }; + assert_eq!(code, C_RESULT_OK, "C ABI last error"); + storage.truncate(out.required_bytes as usize); + storage + } + + /// [`Self::send`] for a command that may be refused: the entry's result, and the response frame + /// after `RESULT_OK` or else the refusal's diagnostic. + fn send_status( + &mut self, + payload: CommandPayload<'_>, + expected: ExpectedRevision, + ) -> (u32, Vec) { self.request += 1; let mut bytes = vec![0_u8; 4_096]; let length = ProtocolCodec::default() @@ -1260,10 +1292,13 @@ impl CAbi { &mut out, ) }; - assert_eq!(code, C_RESULT_OK, "C ABI command"); response.truncate(out.required_bytes as usize); + // Before the drain: a later entry clears the diagnostic. + if code != C_RESULT_OK { + response = self.last_error(); + } self.drain_reliable(); - response + (code, response) } /// One scripted edit as one transaction at the committed revision, and the reply frame's bytes. @@ -2376,3 +2411,91 @@ fn both_hosts_refuse_a_source_of_more_than_two_channels_alike() { ); } } + +/// #1631. A `matrix_mode` switch under a stored pan entry is refused alike by both hosts: the same +/// refusal and the same diagnostic (`control.inactive` at the entry's path, the row rule's +/// own, through the rebuild route), and nothing commits -- the same expected revision still +/// commits the switch together with the entry's retarget to the matrix row, without a rebuild, on both. +/// +/// Red mutation: run the classifier's row rule only when the automation table changes -> the switch +/// commits live on both hosts and the first assertion fails. +#[test] +fn a_mode_switch_under_a_stored_pan_entry_is_refused_alike_on_both_hosts() { + for rate in RATES { + let mut model = model_at(rate); + model.automation.push(session::Automation { + id: id("t2-pan-ride"), + target: address( + Owner::Strip(id(T2.id)), + Section::Builtins, + PAN_ROW, + ParameterChannel::Left, + ), + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: 960, + start_value: 0.0, + end_value: 0.5, + unit: session::ParameterUnit::Linear, + }], + }); + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + let switch = CAbi::edits( + Edit::MatrixMode { + strip: T2, + matrix: true, + }, + None, + ); + + let frame = browser.encode( + CommandPayload::SessionTransactionApply(&switch), + ExpectedRevision::Exact(SessionRevision(browser.revision)), + ); + let (b_code, b_diagnostic) = browser.send_frame(&frame); + let (c_code, c_diagnostic) = c_abi.send_status( + CommandPayload::SessionTransactionApply(&switch), + ExpectedRevision::Exact(SessionRevision(c_abi.revision)), + ); + assert_ne!( + b_code, C_RESULT_OK, + "{rate}: the browser refuses the switch" + ); + // Each host has its own result numbering; the diagnostic is the shared contract. + assert_ne!(c_code, C_RESULT_OK, "{rate}: the C ABI refuses the switch"); + assert_eq!(b_diagnostic, c_diagnostic, "{rate}: one diagnostic"); + let diagnostic = String::from_utf8_lossy(&c_diagnostic); + assert!( + diagnostic.contains("control.inactive") + && diagnostic.contains("$.automation[id=t2-pan-ride].target"), + "{rate}: {diagnostic}" + ); + + // Nothing committed: the revision the refused transaction named still commits, with the + // entry moved to the matrix row in the same transaction. + let mut retargeted = switch.clone(); + retargeted.push(SessionEdit::SetAutomationTarget { + automation_id: id("t2-pan-ride"), + target: address( + Owner::Strip(id(T2.id)), + Section::Builtins, + MATRIX_LL_ROW, + ParameterChannel::Both, + ), + }); + let (b_verdict, b_reply) = browser.transaction_reply(&retargeted); + let c_reply = c_abi.send( + CommandPayload::SessionTransactionApply(&retargeted), + ExpectedRevision::Exact(SessionRevision(c_abi.revision)), + ); + assert_eq!(b_reply, c_reply, "{rate}: the same reply bytes"); + assert!( + // The switch keeps the effective 2x2 the plan holds, so it writes no record. + matches!(&b_verdict, Verdict::Applied { path: EditPath::ModelOnly, revision } + if *revision == model.revision + 1), + "{rate}: committed without a rebuild at the next revision, got {b_verdict:?}" + ); + } +} From 0a4c356a3b651520078e442554291878c40e7284 Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 13:58:43 +0000 Subject: [PATCH 07/12] Boot an empty session on the browser (TBD30, browser part) An empty session's meter lease follows the configured window (not the strip meters), its master-window ring is sized when no strip meter exists, and poll_meters publishes master windows alone with trackCount 0. A parity row boots empty on both hosts, adds the first stereo stem and track by one structural transaction and renders identical bits at four rates; the browser qualification gains an empty-boot leg in three browsers. Co-Authored-By: Claude Opus 5.5 --- ...D30-boot-an-empty-session-on-both-hosts.md | 49 +++ .../qualification/empty-boot-frame.json | 5 + .../host-web/qualification/empty-session.json | 34 ++ hosts/host-web/qualification/qualification.js | 156 ++++++++- hosts/host-web/qualification/run.mjs | 47 ++- .../qualification/sdk-response-entry.ts | 30 +- hosts/host-web/src/lib.rs | 70 +++- hosts/host-web/tests/control_parity.rs | 122 ++++++- hosts/host-web/tests/empty_boot.rs | 308 ++++++++++++++++++ hosts/host-web/tests/empty_boot_fixture.rs | 98 ++++++ 10 files changed, 896 insertions(+), 23 deletions(-) create mode 100644 hosts/host-web/qualification/empty-boot-frame.json create mode 100644 hosts/host-web/qualification/empty-session.json create mode 100644 hosts/host-web/tests/empty_boot.rs create mode 100644 hosts/host-web/tests/empty_boot_fixture.rs diff --git a/.github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md b/.github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md index ef723c4e2..a9e0c03cf 100644 --- a/.github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md +++ b/.github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md @@ -85,3 +85,52 @@ Departures: `crates/capi/tests/empty_boot.rs` joins the realtime policy's unsafe binary that calls the C entries does. Otherwise none. Browser part (empty-boot sizes, parity row, qualification leg) stays with lane W; the stale remark "A session with no source or track does not prepare" in `hosts/host-web/tests/control_parity.rs` (`mono_model`) is theirs to drop. + +## Decision record: browser part (batch 13, lane W) + +What shipped: + +- **Two browser boot-time sizes were fixed at zero** (D2); everything else the spec lists (strip + order, meter frame and header, source map, observation tables, staging) already handled zero and + needed no change: + 1. `AudioWorkletEngineHost::meters_attached` (the meter lease's gate) read the plan's strip + meters, so an empty plan refused the lease with `RESULT_UNSUPPORTED` while the worklet and the + SDK already derive `metersAttached` from the boot option (`liveControlMeterBlocks != 0`): + the SDK's preparation rehearsal (`prepareBrowserSessionInWorker`) would have thrown "Preparation + meter lease refused" on an empty session. It now reads the configured window. + 2. The master-window ring was sized from the first strip meter's queue (`master_capacity`), so a + session that booted empty kept a zero-length ring for its whole life and the first strip's + master peaks were all counted lost. It is `METER_QUEUE_DEPTH` when a window is configured and + no strip meter exists. +- `poll_meters` with no strip meter publishes the master's own windows (a frame of the master's + two peaks, header `trackCount` 0), so a page that leases the meters on an empty mixer gets a + continuous stream; the first strip's adoption restarts delivery as any non-carried swap does. + No allocation: the branch reads the preallocated master ring and writes the preallocated frame. +- A boot with a designated master strip (`liveControlMasterTrackPlusOne != 0`) over an empty + session is still refused (`host.observation.master_track`: the strip does not exist). That is the + honest answer for a strip index; a live master designation is successor work (see the report). + +Gates: + +- Parity row: `hosts/host-web/tests/control_parity.rs` + `both_hosts_boot_an_empty_session_and_add_the_first_stem_alike` (four launch rates): empty boot + (a console slot declared), identical silence, identical control list / state / snapshot bytes, + one structural transaction (source, track, route) with the same verdict on both legs, the stem + fed after the swap and heard bit for bit alike. The stale "does not prepare" remark at + `mono_model` is dropped. +- Browser module: `hosts/host-web/tests/empty_boot.rs` + `an_empty_session_boots_and_grows_by_a_structural_transaction` (own binary, the render-locked + allocator, through the exports): status, boot watermark, control list (session rows only), the + master-only meter frame, zero render-locked allocations on the empty plan, across the first + strip's swap and while the stem plays; the meter frame grows to the strip and the control list's + structure epoch moves. +- Fixtures: `hosts/host-web/tests/empty_boot_fixture.rs` writes `qualification/empty-session.json` + (the live-control session with no source, track or route) and + `qualification/empty-boot-frame.json` (the codec's frame that adds them back), byte-compared. +- Qualification leg (`runEmptyBootQualification`; gates `empty-boot-silence`, `empty-boot-shape`, + `empty-boot-meter`, `empty-boot-first-stem`; the render-allocation list gains `empty-boot` and + `empty-boot-first-stem`): chromium, firefox, webkit with `--check-matrix --self-test-mutations`. + The SDK has no structural-edit builder, so the page sends the codec's frame through the SDK's + endpoint (`sendStructuralFrameThroughSdk`, which stamps the allocator's next request ID and the + committed revision). +- Wasm build: boot high-water growth 20,447,232 bytes (before: 20,447,232). diff --git a/hosts/host-web/qualification/empty-boot-frame.json b/hosts/host-web/qualification/empty-boot-frame.json new file mode 100644 index 000000000..bf612b308 --- /dev/null +++ b/hosts/host-web/qualification/empty-boot-frame.json @@ -0,0 +1,5 @@ +{ + "schema": "miso.host-web.empty-boot-frame", + "revision": "1", + "hex": "4d49534f43544c000100000030000100030000005804000001000000000000000100000000000000030000000000000001000b01d800000002000000000000000100020102000000000100000000000002000b01b8000000010000000000000001000b01a8000000050000000000000001000901130000006c6976652d636f6e74726f6c2d736f7572636500000000000200090147000000626c616b65333a393635626464663264633431353963643265336366393832366365656435613566646430653137373465626261346535633733303532613830623036653566660003000101010000000200000000000000040001010100000003000000000000000500040108000000004100000000000001000b012802000002000000000000000100020102000000000200000000000002000b0108020000010000000000000001000b01f801000008000000000000000100090105000000747261636b00000002000901130000006c6976652d636f6e74726f6c2d736f757263650000000000030001010100000000000000000000000400010101000000010000000000000005000b01c8000000020000000000000001000b01580000000500000000000000010008010100000000000000000000000200060104000000000000000000000003000601040000000000000000000000040006010400000000000000000000000500030104000000000000000000000002000b01580000000500000000000000010008010100000000000000000000000200060104000000000000000000000003000601040000000000000000000000040006010400000000000000000000000500030104000000000000000000000007000b0108000000000000000000000009000b01480000000400000000000000010006010400000000000000000000000200060104000000000000000000000003000801010000000000000000000000040008010100000000000000000000000a000b016800000006000000000000000100010101000000020000000000000002000601040000000000803f00000000030006010400000000000000000000000400060104000000000000000000000005000601040000000000803f000000000600030104000000000000000000000001000b014001000002000000000000000100020102000000000500000000000002000b0120010000010000000000000001000b01100100000700000000000000010009010a000000747261636b2d6d61696e00000000000002000b01380000000300000000000000010001010100000001000000000000000200090105000000747261636b0000000300010101000000070000000000000003000b012800000002000000000000000100010101000000020000000000000002000901080000006d61696e2d6f757404000b0148000000040000000000000001000601040000000000803f00000000020006010400000000000000000000000300060104000000000000000000000004000601040000000000803f00000000050006010400000000000000000000000600080101000000000000000000000007000801010000000000000000000000" +} diff --git a/hosts/host-web/qualification/empty-session.json b/hosts/host-web/qualification/empty-session.json new file mode 100644 index 000000000..0cb23eaba --- /dev/null +++ b/hosts/host-web/qualification/empty-session.json @@ -0,0 +1,34 @@ +{ + "schema_version": 1, + "session_id": "web-browser-live-controls", + "revision": "1", + "sample_rate_hz": 48000, + "quantum_frames": 128, + "render_profile": { + "id": "native", + "mode": "single_thread" + }, + "output_profile": { + "id": "main", + "channels": 2, + "sample_format": "f32_planar" + }, + "sources": [], + "console": { + "pre_insert": [], + "post_insert": [] + }, + "tracks": [], + "submixes": [], + "vcas": [], + "control_smoothing": { + "kind": "default" + }, + "outputs": [ + { + "id": "main-out" + } + ], + "routes": [], + "automation": [] +} diff --git a/hosts/host-web/qualification/qualification.js b/hosts/host-web/qualification/qualification.js index e83ed2491..7aa8cbd55 100644 --- a/hosts/host-web/qualification/qualification.js +++ b/hosts/host-web/qualification/qualification.js @@ -506,6 +506,138 @@ async function runStructuralApplyQualification(createHost, sessionDocument) { }; } +/// TBD30: an empty session in a real browser. The live-control session with no source, no track and +/// no route boots and renders silence, and one structural transaction adds the source, the track +/// and the route back, after which the fed PCM comes out of the output unchanged. +/// +/// Two renders of the same session. The first only boots it and renders: the output is exact zero, +/// the session map and the control list name no source and no strip, the meter stream (the lease is +/// taken on the empty plan) is the master's two peaks with no track, and no allocator call ran on +/// the render thread. The second applies the transaction (the frame is the protocol codec's, the +/// fixture beside the session), feeds the added source before the first quantum, and renders: the +/// first quantum is the swap block, so the output is the submitted PCM, exactly, computed here and +/// not read back; the meter frame grows to the strip, and no render-thread allocator call ran. +async function runEmptyBootQualification(createHost, emptyDocument, frameFixture) { + const boot = async () => { + const context = new OfflineAudioContext(2, LIVE_CONTROL_FRAMES, SAMPLE_RATE); + const host = await createHost({ + context, + document: emptyDocument, + options: bootOptions(LIVE_CONTROL_FRAMES, LIVE_CONTROL_METER_BLOCKS), + simd128ModuleUrl: ARTIFACT_URL, + workletModuleUrl: WORKLET_URL, + }); + host.node.connect(context.destination); + return { context, host }; + }; + const summarise = (frame) => ({ + sequence: frame.sequence, + trackCount: frame.trackCount, + structureEpoch: frame.structureEpoch, + peaks: frame.peaks.length, + }); + const nonzero = (pcm) => pcm.some((channel) => channel.some((sample) => !Object.is(sample, 0))); + + // The empty boot alone. + const quiet = await boot(); + const quietFrames = []; + const quietMap = await quiet.host.sessionMap(); + const quietTracks = await readTracks(quiet.host); + const quietLease = await quiet.host.meters({ + enabled: true, + onFrame: (frame) => quietFrames.push(summarise(frame)), + }); + const quietRendered = await quiet.context.startRendering(); + for (let spin = 0; spin < 8 && quietFrames.length === 0; spin += 1) { + await new Promise((resolve) => setTimeout(resolve, 4)); + } + const quietStatus = await quiet.host.status(); + const quietAllocations = await renderAllocationCount(quiet.host); + await quiet.host.dispose(); + const quietPcm = [quietRendered.getChannelData(0), quietRendered.getChannelData(1)]; + + // The first stem by one structural transaction. + const grown = await boot(); + const grownFrames = []; + const input = [new Float32Array(LIVE_CONTROL_FRAMES), new Float32Array(LIVE_CONTROL_FRAMES)]; + const grownLease = await grown.host.meters({ + enabled: true, + onFrame: (frame) => grownFrames.push(summarise(frame)), + }); + const { sendStructuralFrameThroughSdk } = await import("/sdk/sdk-response-client.js"); + const reply = await sendStructuralFrameThroughSdk(grown.host, frameFixture.hex, frameFixture.revision); + const afterMap = await grown.host.sessionMap(); + const afterTracks = await readTracks(grown.host); + for (let block = 0; block < LIVE_CONTROL_BLOCKS; block += 1) { + const planes = sourcePlanes(block); + for (let channel = 0; channel < 2; channel += 1) { + input[channel].set(planes[channel], block * QUANTUM_FRAMES); + } + const acknowledgement = await grown.host.submitSource({ + sourceId: "live-control-source", + generation: 1n, + startFrame: BigInt(block * QUANTUM_FRAMES), + sampleRateHz: SAMPLE_RATE, + planes, + frames: QUANTUM_FRAMES, + endOfRegion: block === LIVE_CONTROL_BLOCKS - 1, + }); + if (acknowledgement.result !== 0) throw new Error("empty-boot prefill rejected"); + } + const pending = await grown.host.status(); + const grownRendered = await grown.context.startRendering(); + for (let spin = 0; spin < 8 && grownFrames.length === 0; spin += 1) { + await new Promise((resolve) => setTimeout(resolve, 4)); + } + const grownStatus = await grown.host.status(); + const grownAllocations = await renderAllocationCount(grown.host); + await grown.host.dispose(); + const actual = [grownRendered.getChannelData(0), grownRendered.getChannelData(1)]; + let exact = true; + for (let channel = 0; channel < 2; channel += 1) { + for (let at = 0; at < LIVE_CONTROL_FRAMES; at += 1) { + if (!Object.is(actual[channel][at], input[channel][at])) exact = false; + } + } + return { + boot: { + sources: quietMap.sources.length, + tracks: quietTracks.length, + metersAttached: quietMap.metersAttached, + meterLeaseResult: quietLease.result, + silent: !nonzero(quietPcm), + renderedQuanta: quietStatus.renderedQuanta.toString(), + expectedQuanta: LIVE_CONTROL_BLOCKS, + meterFrames: quietFrames.length, + meterTrackCounts: [...new Set(quietFrames.map((entry) => entry.trackCount))], + meterWidths: [...new Set(quietFrames.map((entry) => entry.peaks))], + renderAllocations: quietAllocations, + }, + firstStem: { + replyStatus: reply.status, + commandPath: reply.path, + replyBytes: reply.replyBytes, + sourcesAfter: afterMap.sources.length, + tracksAfter: afterTracks, + meterLeaseResult: grownLease.result, + initialRevision: frameFixture.revision, + pendingRevision: pending.appliedRevision.toString(), + committedRevision: reply.revision, + finalRevision: grownStatus.appliedRevision.toString(), + finalSample: grownStatus.appliedSample.toString(), + exactInputOutput: exact, + heard: nonzero(actual), + expectedDigest: await pcmDigest(input, LIVE_CONTROL_FRAMES), + renderedDigest: await pcmDigest(actual, LIVE_CONTROL_FRAMES), + meterFrames: grownFrames.length, + meterTrackCounts: [...new Set(grownFrames.map((entry) => entry.trackCount))], + meterWidths: [...new Set(grownFrames.map((entry) => entry.peaks))], + structureEpochs: [...new Set(grownFrames.map((entry) => entry.structureEpoch))], + renderAllocations: grownAllocations, + }, + }; +} + /// Issue #143 E12: subscribe -> nonzero `trackGrDb` in a real browser, and an unarmed tap publishes none. /// /// Two offline renders of the same sixteen blocks over the same session, differing only in whether @@ -841,7 +973,7 @@ export async function runQualification() { const resumeRecord = countResumes(); const [ { createMisoAudioWorkletHost }, expectedResponse, sessionResponse, sourceResponse, stallResponse, - liveControlResponse, observationResponse, + liveControlResponse, observationResponse, emptyResponse, emptyFrameResponse, ] = await Promise.all([ import("/artifacts/miso-engine-v1-audio-worklet-host.js"), @@ -851,9 +983,12 @@ export async function runQualification() { fetch("/qualification/stall-session.json"), fetch("/qualification/live-control-session.json"), fetch("/qualification/observation-session.json"), + fetch("/qualification/empty-session.json"), + fetch("/qualification/empty-boot-frame.json"), ]); if (!expectedResponse.ok || !sessionResponse.ok || !sourceResponse.ok || !stallResponse.ok - || !liveControlResponse.ok || !observationResponse.ok) { + || !liveControlResponse.ok || !observationResponse.ok || !emptyResponse.ok + || !emptyFrameResponse.ok) { throw new Error("qualification fixture fetch failed"); } const expected = await expectedResponse.json(); @@ -866,6 +1001,9 @@ export async function runQualification() { // Issue #143 E12: the live-control session plus one compressor, so an armed tap has a real // reduction. const observationDocument = new TextEncoder().encode(await observationResponse.text()); + // TBD30: the live-control session with no source, track or route, and the frame that adds them. + const emptyDocument = new TextEncoder().encode(await emptyResponse.text()); + const emptyFrame = await emptyFrameResponse.json(); const simd128 = WebAssembly.validate(SIMD128_PROBE); const sdkResponse = simd128 && new URL(import.meta.url).searchParams.get("sdk") === "1" ? await (await import("/sdk/sdk-response-client.js")).runSdkResponseQualification() @@ -887,6 +1025,7 @@ export async function runQualification() { corpus: null, liveControls: null, structuralApply: null, + emptyBoot: null, observation: null, stall: null, stagingReads: null, @@ -941,6 +1080,15 @@ export async function runQualification() { name: error?.name, message: error?.message, ...error, diagnostic, })}`); } + let emptyBoot; + try { + emptyBoot = await runEmptyBootQualification(createMisoAudioWorkletHost, emptyDocument, emptyFrame); + } catch (error) { + const diagnostic = await diagnoseReady(emptyDocument); + throw new Error(`empty-boot qualification failed: ${diagnosticJson({ + name: error?.name, message: error?.message, ...error, diagnostic, + })}`); + } let observation; try { observation = await runObservationQualification( @@ -987,6 +1135,7 @@ export async function runQualification() { }, liveControls: live, structuralApply: structural, + emptyBoot, observation, stall, stagingReads, @@ -998,6 +1147,8 @@ export async function runQualification() { }))), { workload: "live-control", count: live.renderAllocations }, { workload: "structural-apply", count: structural.renderAllocations }, + { workload: "empty-boot", count: emptyBoot.boot.renderAllocations }, + { workload: "empty-boot-first-stem", count: emptyBoot.firstStem.renderAllocations }, { workload: "observation-armed", count: observation.armed.renderAllocations }, { workload: "observation-disarmed", count: observation.disarmed.renderAllocations }, { workload: "stall", count: stall.renderAllocations }, @@ -1028,6 +1179,7 @@ export const qualificationBootContract = Object.freeze({ typedUnsupportedAttestation, runLiveControlQualification, runStructuralApplyQualification, + runEmptyBootQualification, runObservationRun, runStallQualification, runStagingReadRun, diff --git a/hosts/host-web/qualification/run.mjs b/hosts/host-web/qualification/run.mjs index 9cbc154fd..f1558bebc 100644 --- a/hosts/host-web/qualification/run.mjs +++ b/hosts/host-web/qualification/run.mjs @@ -28,6 +28,9 @@ const MUTATIONS = [ // moved the output off the declared delay, or whose meter stream was not the successor's. "structural-apply-path", "structural-apply-early", "structural-apply-output", "structural-apply-meter", + // TBD30: an empty session that did not boot silent and empty, one whose first stem was not a + // rebuild heard unchanged, and one whose meter stream did not follow the strip count. + "empty-boot-silence", "empty-boot-shape", "empty-boot-first-stem", "empty-boot-meter", // Issue #143 E12: the observation row. `observation-armed` is the eval's named red mutation -- // a run whose armed tap published nothing, which is exactly what a browser that lost the // transport would produce. @@ -246,6 +249,44 @@ function validate(browserName, result) { && Number.isSafeInteger(structural.structureEpochs[0]) && structural.structureEpochs[0] >= 1, `the meter stream after the swap was not the successor's with one structure epoch: ${JSON.stringify(structural)}`); + // TBD30: an empty session in a real browser. It boots and renders exact silence with no source + // and no strip in the session map and the control list, the meter stream is the master's two + // peaks alone, and one structural transaction adds the source, the track and the route: the fed + // PCM then comes out unchanged from the swap block, the applied revision was not early, and the + // meter stream grew to the strip. + const empty = result.emptyBoot; + gate(browserName, "empty-boot-silence", empty?.boot?.silent === true + && empty?.boot?.renderedQuanta === String(empty?.boot?.expectedQuanta), + `the empty session did not render exact silence for every quantum: ${JSON.stringify(empty?.boot)}`); + gate(browserName, "empty-boot-shape", empty?.boot?.sources === 0 && empty?.boot?.tracks === 0 + && empty?.boot?.metersAttached === true, + `the empty session map or control list named a source or a strip: ${JSON.stringify(empty?.boot)}`); + gate(browserName, "empty-boot-meter", empty?.boot?.meterLeaseResult === 0 + && empty?.boot?.meterFrames >= 1 + && JSON.stringify(empty?.boot?.meterTrackCounts) === "[0]" + && JSON.stringify(empty?.boot?.meterWidths) === "[2]", + `the empty session's meter stream was not the master's two peaks: ${JSON.stringify(empty?.boot)}`); + const first = empty?.firstStem; + gate(browserName, "empty-boot-first-stem", first?.replyStatus === "ok" && first?.commandPath === "rebuild" + && first?.replyBytes > 0 + && first?.sourcesAfter === 1 + && JSON.stringify(first?.tracksAfter) === JSON.stringify(["track"]) + && /^[0-9]+$/.test(first?.committedRevision ?? "") + && first?.pendingRevision === first?.initialRevision + && first?.finalRevision === first?.committedRevision + && BigInt(first?.finalRevision ?? "0") > BigInt(first?.initialRevision ?? "0") + && BigInt(first?.finalSample ?? "-1") === 0n, + `the first stem was not committed once, by a structural transaction, and applied at the swap block: ${JSON.stringify(first)}`); + gate(browserName, "empty-boot-first-stem", first?.heard === true && first?.exactInputOutput === true + && first?.renderedDigest === first?.expectedDigest, + "the first stem was not heard exactly as it was fed"); + gate(browserName, "empty-boot-meter", first?.meterLeaseResult === 0 && first?.meterFrames >= 1 + && JSON.stringify(first?.meterTrackCounts) === "[1]" + && JSON.stringify(first?.meterWidths) === "[4]" + && Array.isArray(first?.structureEpochs) && first.structureEpochs.length === 1 + && Number.isSafeInteger(first.structureEpochs[0]) && first.structureEpochs[0] >= 1, + `the meter stream after the first stem was not the strip's with one structure epoch: ${JSON.stringify(first)}`); + // Issue #143 E12: subscribe -> nonzero `trackGrDb`, an unarmed tap publishes none, with `firstSample` // monotonic and the windows tiling. The two runs render the *same* sixteen blocks, so their // audio must be identical: arming a declared tap may not move a sample. @@ -326,7 +367,7 @@ function validate(browserName, result) { // executor does behind its `call_indirect`, which the static gate cannot see. const allocations = result.renderAllocations; gate(browserName, "render-allocations", Array.isArray(allocations) - && allocations.length === 11 + && allocations.length === 13 && allocations.every((row) => typeof row?.workload === "string" && row.count === 0), `a worklet instance allocated on its render thread: ${JSON.stringify(allocations)}`); if (result.sdkResponse !== null) validateSdkResponse(browserName, result.sdkResponse); @@ -881,6 +922,10 @@ function mutate(result, mutation) { } if (mutation === "structural-apply-output") copy.structuralApply.exactDelayedOutput = false; if (mutation === "structural-apply-meter") copy.structuralApply.meterFrames = 0; + if (mutation === "empty-boot-silence") copy.emptyBoot.boot.silent = false; + if (mutation === "empty-boot-shape") copy.emptyBoot.boot.tracks = 1; + if (mutation === "empty-boot-first-stem") copy.emptyBoot.firstStem.exactInputOutput = false; + if (mutation === "empty-boot-meter") copy.emptyBoot.firstStem.meterWidths = [2]; // Issue #143 E12's named red mutation: `observationArmed = 0`. if (mutation === "observation-armed") copy.observation.armed.maximumTrackGrDb = 0; if (mutation === "observation-disarmed") copy.observation.disarmed.maximumTrackGrDb = 1; diff --git a/hosts/host-web/qualification/sdk-response-entry.ts b/hosts/host-web/qualification/sdk-response-entry.ts index 6d1b4f984..3f1652564 100644 --- a/hosts/host-web/qualification/sdk-response-entry.ts +++ b/hosts/host-web/qualification/sdk-response-entry.ts @@ -1,7 +1,8 @@ import { WasmBoundary } from "../../../sdk/src/core/boundary.ts"; import { EngineLiveControls } from "../../../sdk/src/core/live-controls.ts"; import { ControlTransactionQueue } from "../../../sdk/src/core/control-queue.ts"; -import { createBrowserLiveControls, createBrowserStripOrder } from "../../../sdk/src/browser/live-controls.ts"; +import { createBrowserControlListTransport, createBrowserLiveControls, createBrowserStripOrder } from "../../../sdk/src/browser/live-controls.ts"; +import { decodeControlReply } from "../../../sdk/src/core/control-frame.ts"; import type { MisoAudioWorkletHost } from "../../../sdk/src/browser/shipped-host.d.ts"; import { encodeLaneEdits } from "../../../sdk/src/core/writer.ts"; import { MisoEngineAsset } from "../../../sdk/src/core/asset.ts"; @@ -2496,6 +2497,33 @@ export async function applyDelayThroughSdk( }; } +/** + * Send one structural command frame the protocol codec wrote (TBD30: the frame that adds the first + * stem) through the SDK's endpoint for the host. The SDK has no encoder for a structural edit, and + * the engine's replay cache refuses a request ID twice, so the frame takes the next ID of the + * host's one allocator and the committed revision, the two header words that depend on the engine. + * Returns the core's verdict: its status, the edit path and the committed revision. + */ +export async function sendStructuralFrameThroughSdk( + host: MisoAudioWorkletHost, + hex: string, + revision: string, +): Promise<{ status: string; path: string | undefined; revision: string; replyBytes: number }> { + const transport = await createBrowserControlListTransport(host); + const frame = Uint8Array.from(hex.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16)); + const view = new DataView(frame.buffer); + view.setBigUint64(24, transport.requestIds.next(), true); + view.setBigUint64(32, BigInt(revision), true); + const response = await transport.send(frame); + const reply = decodeControlReply(response); + return { + status: reply.status, + path: reply.applied?.editPath, + revision: String(reply.revision), + replyBytes: response.length, + }; +} + /** * The track IDs of the host's running session, in the core's own order (the owner headers of its * control list, #1564 D5). The worklet's session map no longer carries them (#1565B), and the diff --git a/hosts/host-web/src/lib.rs b/hosts/host-web/src/lib.rs index 530f75b57..13c9dc9ff 100644 --- a/hosts/host-web/src/lib.rs +++ b/hosts/host-web/src/lib.rs @@ -3085,18 +3085,19 @@ impl AudioWorkletEngineHost { (meters, scans) } - /// Whether meter observers were attached at preparation (issue #137 D2). + /// Whether the host was prepared with a meter window (issue #137 D2). It is the + /// configuration's word, not the plan's strip count: a session with no strip (TBD30) is + /// metered by the master alone, and takes the lease so that the first strip's meters are + /// delivered from the block that adopts them. #[must_use] pub fn meters_attached(&self) -> bool { - self.ready - .as_ref() - .is_some_and(|ready| !ready.shape.meter_pending.is_empty()) + self.ready.is_some() && self.options.live_control_meter_blocks != 0 } /// Take or release the meter lease. Returns the frozen result code. /// - /// A lease over a host that attached no observers is refused with [`RESULT_UNSUPPORTED`]: the - /// caller asked for numbers this preparation cannot produce, and silently reporting zeros + /// A lease over a host prepared with no meter window is refused with [`RESULT_UNSUPPORTED`]: + /// the caller asked for numbers this preparation cannot produce, and silently reporting zeros /// would be worse than saying so. pub fn set_meter_lease(&mut self, enabled: bool) -> u32 { if self.status.state != STATE_READY { @@ -3570,9 +3571,6 @@ impl AudioWorkletEngineHost { // keeps in range. This export is called from `process()`, so the shipped artifact's // call-graph gate covers it as it covers the render export. let meter_count = companions.meters.len(); - if meter_count == 0 { - return 0; - } // The render path is the sole producer of this fixed-capacity ring and increments the // count only when the configured master interval closes. An empty ring therefore proves // that no coherent track/master publication can exist. Preserve pending/public bytes and @@ -3580,6 +3578,39 @@ impl AudioWorkletEngineHost { if ready.master_count == 0 { return 0; } + let mut windows = 0_u32; + let mut folded_start = 0_u64; + let mut folded_end = 0_u64; + let mut folded = false; + if meter_count == 0 { + // A plan with no strip (TBD30) has no strip window to pair the master with, so the + // master's own windows are the stream: the frame is the master's three words. + let Some(front) = ready + .master_windows + .get(ready.master_read) + .and_then(Option::as_ref) + .copied() + else { + return 0; + }; + if front.start_sample < self.meter_activation_sample + || front.end_sample <= front.start_sample + { + let _ = ready.pop_master_window(); + ready.meter_loss_count = ready.meter_loss_count.saturating_add(1); + return 0; + } + if ready.shape.meter_header.sequence > 0 + && ready.shape.meter_header.reserved[0] == ready.meter_generation + && front.start_sample != ready.shape.meter_header.end_sample + { + ready.meter_loss_count = ready.meter_loss_count.saturating_add(1); + } + folded_start = front.start_sample; + folded_end = front.end_sample; + folded = true; + windows = 1; + } // Issue #1448 D2: each meter's queue pops at most its own count read here, at entry, capped // at the configured depth, whatever a producer on another thread publishes meanwhile. A // pass that does not end the loop has cleared at least one pending slot, and the next pass @@ -3596,10 +3627,6 @@ impl AudioWorkletEngineHost { for remaining in ready.shape.meter_remaining.iter() { passes = passes.saturating_add(*remaining); } - let mut windows = 0_u32; - let mut folded_start = 0_u64; - let mut folded_end = 0_u64; - let mut folded = false; for _ in 0..passes { for ((meter, pending), remaining) in companions .meters @@ -6219,10 +6246,19 @@ fn compile_ready( report.largest_named_allocation_bytes = report .largest_named_allocation_bytes .max(report.largest_bridge_allocation_bytes); - let master_capacity = companions - .meters() - .first() - .map_or(0, |meter| meter.consumer.capacity()); + // The master windows queue as deep as a strip meter's. A session with no strip (TBD30) has no + // meter to size it from, and still meters its master when a window is configured: the + // ring is the boot's, so it must already hold the depth the first strip will use. + let master_capacity = companions.meters().first().map_or_else( + || { + if options.live_control_meter_blocks == 0 { + 0 + } else { + METER_QUEUE_DEPTH + } + }, + |meter| meter.consumer.capacity(), + ); let pending_meter_bytes = rows.pending_bytes; let remaining_meter_bytes = rows.remaining_bytes; let carried_meter_bytes = rows.carried_bytes; diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 9a3b9cd6f..d0808ac2c 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -2138,8 +2138,7 @@ fn longest_id() -> String { } /// A session whose only source is mono (ID `m`), with one track and one route, no console slot, -/// no submix and no VCA, and the fixture's stereo source, track and route to add later. (A session -/// with no source or track does not prepare: its graph is empty.) +/// no submix and no VCA, and the fixture's stereo source, track and route to add later. fn mono_model( rate: u32, ) -> ( @@ -2376,3 +2375,122 @@ fn both_hosts_refuse_a_source_of_more_than_two_channels_alike() { ); } } + +// --------------------------------------------------------------------------------------------- +// An empty session boots and grows by a structural transaction (TBD30) +// --------------------------------------------------------------------------------------------- + +/// Both hosts boot a session with no source and no track (one console slot stays declared), render +/// silence, and report one empty mixer; one structural transaction then adds a stereo stem, its +/// track and its route, and both hosts render the same bits for the fed stem. The C ABI leg alone +/// is `crates/capi/tests/empty_boot.rs`. +/// +/// Test value: red if the browser has a boot-time size that fails at zero (a strip order, a meter +/// frame, a source map, an observation table or a staging buffer sized for the first strip), if its +/// silence or its session map, control list or snapshot differ from the C ABI's, or if the first +/// strip cannot arrive through the successor paths and be heard bit for bit like the C ABI's. +#[test] +fn both_hosts_boot_an_empty_session_and_add_the_first_stem_alike() { + for rate in RATES { + 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(); + assert_eq!(model.console.pre_insert.len(), 1, "a declared console slot"); + source.id = id("stem"); + source.frames = u64::from(QUANTUM) * (BLOCKS + 8); + 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 edits = [ + SessionEdit::UpsertSource { source }, + SessionEdit::UpsertTrack { track }, + SessionEdit::UpsertRoute { route }, + ]; + + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + assert_eq!(browser.host.session_source_count(), 0, "{rate} Hz: empty"); + for block in 0..3 { + assert_eq!(browser.host.render_next(), host_web::RESULT_OK); + let b = bits(browser.host.output_pcm().expect("browser output")); + let c = c_abi.render(block); + assert_eq!(b, c, "{rate} Hz: block {block}: the empty mixers agree"); + assert!(b.iter().all(|word| *word == 0), "{rate} Hz: silence"); + } + 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, "{rate} Hz: the empty control list agrees"); + assert_eq!( + b_snapshot, c_snapshot, + "{rate} Hz: the empty snapshot agrees" + ); + + let (rebuilt, _) = browser.transaction_reply(&edits); + let c_reply = c_abi.send( + CommandPayload::SessionTransactionApply(&edits), + ExpectedRevision::Exact(SessionRevision(c_abi.revision)), + ); + // The two legs have sent different numbers of frames, so their request IDs differ: the + // verdicts are compared, not the bytes. + assert_eq!( + rebuilt, + decode_verdict(&c_reply), + "{rate} Hz: the same verdict" + ); + assert!( + matches!( + rebuilt, + Verdict::Applied { + path: EditPath::Rebuild, + .. + } + ), + "{rate} Hz: the first stem is a rebuild, got {rebuilt:?}" + ); + assert_eq!(browser.host.render_next(), host_web::RESULT_OK); + let _ = c_abi.render(3); + assert_eq!(browser.host.session_source_count(), 1, "{rate} Hz: the map"); + assert_eq!(browser.host.session_source_id(0), Some("stem")); + + assert_eq!(browser.host.seek_source(b"stem", 2, 0), host_web::RESULT_OK); + // SAFETY: The session is live and used by this thread alone; the name outlives the call. + let code = + unsafe { capi::miso_engine_v1_source_seek(c_abi.session, b"stem".as_ptr(), 4, 2, 0) }; + assert_eq!(code, C_RESULT_OK, "{rate} Hz: C ABI seek"); + let mut heard = false; + for block in 4..12 { + let [left, right] = planes(0, block); + assert_eq!( + browser.host.submit_source( + b"stem", + 2, + (block - 4) * u64::from(QUANTUM), + rate, + &[&left, &right], + QUANTUM, + false, + ), + host_web::RESULT_OK, + "{rate} Hz: the browser takes the first stem's PCM" + ); + c_abi.submit_from("stem", 0, block, block - 4, 2); + assert_eq!(browser.host.render_next(), host_web::RESULT_OK); + let b = bits(browser.host.output_pcm().expect("browser output")); + let c = c_abi.render(block); + assert_eq!(b, c, "{rate} Hz: block {block}: the two hosts render alike"); + heard |= b.iter().any(|word| f32::from_bits(*word) != 0.0); + } + assert!(heard, "{rate} Hz: the first stem is heard"); + } +} diff --git a/hosts/host-web/tests/empty_boot.rs b/hosts/host-web/tests/empty_boot.rs new file mode 100644 index 000000000..8890186d7 --- /dev/null +++ b/hosts/host-web/tests/empty_boot.rs @@ -0,0 +1,308 @@ +//! TBD30 (browser part): a session with no source and no track boots on the browser module, and a +//! structural transaction adds the first stereo stem and its track. +//! +//! Both gates run through the module's exports on a fresh thread, as the worklet makes the calls, +//! inside the render-locked window, so the instance's counters read what a render call and a meter +//! poll allocated and freed: +//! +//! * **Boot.** The empty mixer (one console slot stays declared) reports one consistent shape: the +//! status and the watermark of the boot revision, a meter frame of the three master words with no +//! track and no submix, no source in the session map, a control list that names the session's own +//! rows and no strip, and silence at the output, with zero render-locked allocations. +//! * **Growth.** One structural transaction adds a source, a track and a route. The successor adopts +//! at the next block boundary without a render-locked allocation, the meter frame grows to the +//! strip's words, the session map lists the stem, and the stem fed through the export is heard. +//! +//! The bit-for-bit comparison with the C ABI is `control_parity.rs`. + +#![cfg(feature = "test-support")] + +use std::alloc::System; + +use host_web::{RESULT_OK, RenderLockedAllocator, WebBootOptions, native_staging}; +use protocol::{ + CommandPayload, ControlListRequest, DecodeScratch, DecodedSuccessResponsePayload, + DecodedTypedResponseFrame, EditPath, ExpectedRevision, ProtocolCodec, RequestId, SessionEdit, + SessionRevision, TypedCommandFrame, +}; +use session::{RouteSource, SessionModel, StableId, canonical_session_json, parse_session_json}; + +#[global_allocator] +static ALLOCATOR: RenderLockedAllocator = RenderLockedAllocator(System); + +const FIXTURE: &str = include_str!("../../../fixtures/session/v1/parametric-eq-nine-track.json"); +const RATES: [u32; 4] = [44_100, 48_000, 88_200, 96_000]; +const QUANTUM_FRAMES: u32 = 128; +const STEM: &[u8] = b"stem"; + +/// The fixture cut to an empty mixer: no source, no track, no route and no VCA. The console slot +/// stays declared, so the track added later carries it. +fn empty_model(rate: u32) -> SessionModel { + let mut model = parse_session_json(FIXTURE).expect("the fixture parses"); + model.sample_rate_hz = rate; + model.quantum_frames = QUANTUM_FRAMES; + model.sources.clear(); + model.tracks.clear(); + model.routes.clear(); + model.vcas.clear(); + assert_eq!(model.console.pre_insert.len(), 1, "a declared console slot"); + model +} + +/// The stem, its track and the route of that track into the output. +fn stem_edits() -> Vec { + let fixture = parse_session_json(FIXTURE).expect("the fixture parses"); + let mut source = fixture.sources[0].clone(); + source.id = StableId::parse("stem").expect("source ID"); + source.frames = u64::from(QUANTUM_FRAMES) * 64; + let mut track = fixture.tracks[0].clone(); + track.id = StableId::parse("t1").expect("track ID"); + track.source_id = source.id.clone(); + let mut route = fixture.routes[0].clone(); + route.id = StableId::parse("t1-main").expect("route ID"); + let RouteSource::Track { track_id, .. } = &mut route.source else { + panic!("the fixture routes a track") + }; + *track_id = track.id.clone(); + vec![ + SessionEdit::UpsertSource { source }, + SessionEdit::UpsertTrack { track }, + SessionEdit::UpsertRoute { route }, + ] +} + +fn frame(payload: CommandPayload<'_>, request: u64, expected: ExpectedRevision) -> Vec { + let codec = ProtocolCodec::default(); + let mut bytes = vec![0_u8; codec.limits().max_frame_bytes]; + let length = codec + .encode_command_frame_into( + &TypedCommandFrame { + request_id: RequestId::new(request).expect("nonzero request"), + expected_revision: expected, + payload, + }, + &mut bytes, + ) + .expect("a command frame"); + bytes.truncate(length); + bytes +} + +/// The rows of the control list, paged through the export, and the structure epoch. +fn control_list(handle: u32, request: &mut u64) -> (u64, Vec) { + let mut rows = Vec::new(); + let (mut epoch, mut after) = (0, 0); + loop { + *request += 1; + let list = ControlListRequest { + structure_epoch: epoch, + after_handle: after, + limit: 64, + owner: None, + }; + let (result, bytes) = native_staging::control_command( + handle, + &frame( + CommandPayload::ControlListGet(&list), + *request, + ExpectedRevision::Any, + ), + ) + .expect("a live handle"); + assert_eq!(result, RESULT_OK, "the control list"); + let mut fields = [0_u16; 4096]; + let DecodedTypedResponseFrame::Success { + payload: DecodedSuccessResponsePayload::ControlListPage(page), + .. + } = ProtocolCodec::default() + .decode_typed_response(&bytes, &mut DecodeScratch::new(&mut fields)) + .expect("a control page") + else { + panic!("expected a control list page") + }; + rows.extend(page.entries.iter().map(|entry| format!("{entry:?}"))); + if page.eof { + return (page.structure_epoch, rows); + } + (epoch, after) = (page.structure_epoch, page.last_handle); + } +} + +/// One render call and the meter poll after it, with the render-locked allocations they made. +fn render_and_poll(handle: u32) -> (Vec, u32, u32) { + let before = host_web::miso_engine_web_v1_render_allocation_count(); + let result = host_web::miso_engine_web_v1_render(handle, QUANTUM_FRAMES); + assert_eq!(result, RESULT_OK, "the render"); + let windows = host_web::miso_engine_web_v1_meter_poll(handle); + let allocations = host_web::miso_engine_web_v1_render_allocation_count() - before; + ( + native_staging::output_pcm(handle).expect("output"), + windows, + allocations, + ) +} + +/// The empty mixer boots at every launch rate, and the status, the watermark, the meter frame, the +/// session map and the control list describe one empty mixer. A structural transaction adds the +/// first stem: the render half allocates nothing across the swap, and the stem is heard. +/// +/// Test value: red if a browser boot-time size fails at zero (a strip order, a meter frame or its +/// header, the source map, the observation tables or the staging), if the meter frame of an empty +/// mixer is not the master's three words or its header counts a strip, if render or a poll allocates +/// on an empty plan or across the first strip's swap, or if the first strip's frame, source map or +/// audio does not arrive through the successor path. +#[test] +fn an_empty_session_boots_and_grows_by_a_structural_transaction() { + for rate in RATES { + std::thread::spawn(move || { + let model = empty_model(rate); + let options = WebBootOptions { + require_sample_rate_hz: rate, + require_quantum_frames: QUANTUM_FRAMES, + live_control_meter_blocks: 1, + live_control_observation_taps: 4, + ..WebBootOptions::explicit_defaults() + }; + let document = canonical_session_json(&model).expect("canonical session"); + let length = native_staging::boot(options, document.as_bytes()).expect("stages"); + let handle = host_web::miso_engine_web_v1_boot(length); + assert_ne!(handle, 0, "{rate} Hz: the empty session boots"); + assert_eq!( + host_web::miso_engine_web_v1_meter_lease(handle, 1), + RESULT_OK + ); + + let status = native_staging::status(handle).expect("a status"); + assert_eq!( + (status.state, status.sample_rate_hz, status.quantum_frames), + (host_web::STATE_READY, rate, QUANTUM_FRAMES), + "{rate} Hz: ready" + ); + let mut request = 0; + let (empty_epoch, empty_rows) = control_list(handle, &mut request); + assert!( + empty_rows + .first() + .is_some_and(|row| row.contains("owner_kind: Session")) + && empty_rows + .iter() + .all(|row| !row.contains("owner_kind: Strip")), + "{rate} Hz: only the session owns rows: {empty_rows:?}" + ); + + let mut published = 0; + for block in 0..4 { + let (pcm, windows, allocations) = render_and_poll(handle); + assert!( + pcm.iter().all(|sample| sample.to_bits() == 0), + "{rate} Hz: silence" + ); + assert_eq!(allocations, 0, "{rate} Hz: block {block} allocates nothing"); + if windows > 0 { + published += 1; + let (header, words) = native_staging::meter_frame(handle).expect("a frame"); + assert_eq!( + (header.track_count, header.submix_count, words.len()), + (0, 0, 3), + "{rate} Hz: the master's words alone" + ); + assert!( + words.iter().all(|word| *word == 0.0), + "{rate} Hz: silent meters" + ); + } + } + assert!( + published > 0, + "{rate} Hz: the master's frames are published" + ); + let status = native_staging::status(handle).expect("a status"); + assert_eq!(status.rendered_quanta, 4); + assert_eq!( + (status.watermark_revision, status.watermark_first_sample), + (model.revision, 0), + "{rate} Hz: the boot watermark" + ); + + // One structural transaction adds the first stem. + request += 1; + let revision = host_web::miso_engine_web_v1_control_revision(handle); + let edits = stem_edits(); + let (result, reply) = native_staging::control_command( + handle, + &frame( + CommandPayload::SessionTransactionApply(&edits), + request, + ExpectedRevision::Exact(SessionRevision(revision)), + ), + ) + .expect("a live handle"); + assert_eq!(result, RESULT_OK, "{rate} Hz: the transaction publishes"); + let mut fields = [0_u16; 64]; + let DecodedTypedResponseFrame::Success { + payload: DecodedSuccessResponsePayload::SessionTransactionApplied(applied), + .. + } = ProtocolCodec::default() + .decode_typed_response(&reply, &mut DecodeScratch::new(&mut fields)) + .expect("a reply") + else { + panic!("{rate} Hz: the transaction is refused") + }; + assert_eq!(applied.edit_path, EditPath::Rebuild); + + let mut heard = false; + let mut grown = false; + for block in 4..14 { + if block == 5 { + // The swap block ran; a seek opens a fresh generation at the render clock. + assert!(native_staging::source_id(handle, STEM)); + assert_eq!( + host_web::miso_engine_web_v1_source_seek(handle, STEM.len() as u32, 2, 0), + RESULT_OK + ); + } + if block >= 5 { + assert!(native_staging::source_id(handle, STEM)); + assert!(native_staging::source_pcm(handle, 0.25)); + assert_eq!( + host_web::miso_engine_web_v1_source_submit( + handle, + STEM.len() as u32, + 2, + (block - 5) * u64::from(QUANTUM_FRAMES), + 2, + QUANTUM_FRAMES, + 0, + ), + RESULT_OK, + "{rate} Hz: the stem takes PCM" + ); + } + let (pcm, windows, allocations) = render_and_poll(handle); + assert_eq!(allocations, 0, "{rate} Hz: block {block} allocates nothing"); + heard |= pcm.iter().any(|sample| *sample != 0.0); + if windows > 0 { + let (header, words) = native_staging::meter_frame(handle).expect("a frame"); + if header.track_count == 1 { + assert_eq!(words.len(), 3 + 3, "{rate} Hz: the strip's words"); + grown = true; + } + } + if block == 4 { + assert_eq!(host_web::miso_engine_web_v1_service(handle), RESULT_OK); + } + } + assert!(heard, "{rate} Hz: the stem is heard"); + assert!(grown, "{rate} Hz: the meter frame grew to the strip"); + let (epoch, rows) = control_list(handle, &mut request); + assert!(epoch > empty_epoch, "{rate} Hz: the structure epoch moved"); + assert!( + rows.len() > empty_rows.len() && rows.iter().any(|row| row.contains("t1")), + "{rate} Hz: the strip's rows are listed" + ); + assert_eq!(host_web::miso_engine_web_v1_dispose(handle), RESULT_OK); + }) + .join() + .expect("the run completes"); + } +} diff --git a/hosts/host-web/tests/empty_boot_fixture.rs b/hosts/host-web/tests/empty_boot_fixture.rs new file mode 100644 index 000000000..5b785a270 --- /dev/null +++ b/hosts/host-web/tests/empty_boot_fixture.rs @@ -0,0 +1,98 @@ +//! TBD30: the empty-boot leg of the browser qualification boots the live-control session with its +//! source, its track and its route taken out, then adds them back by one structural transaction. +//! +//! The SDK has no encoder for a structural edit, so the page sends the frame the protocol codec +//! wrote. This test writes the two files the page fetches beside the live-control session they +//! derive from: `qualification/empty-session.json` (that session, canonical, with no source, no +//! track and no route) and `qualification/empty-boot-frame.json` (the `SESSION_TRANSACTION_APPLY` +//! frame that adds the live-control session's source, track and route, request ID 1, expecting the +//! empty session's revision). Both are wire or document bytes, so a byte comparison is the right +//! gate: it turns red when the codec or the live-control session changes without them following. +//! +//! Run with `MISO_ENGINE_BLESS_FIXTURES=1` to rewrite the files after a deliberate change. + +use std::fmt::Write as _; +use std::path::PathBuf; + +use protocol::{ + CommandPayload, ExpectedRevision, ProtocolCodec, RequestId, SessionEdit, SessionRevision, + TypedCommandFrame, +}; +use session::{canonical_session_json, parse_session_json}; + +const BASE: &str = "qualification/live-control-session.json"; +const EMPTY: &str = "qualification/empty-session.json"; +const FRAME: &str = "qualification/empty-boot-frame.json"; + +fn path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(name) +} + +/// The two files' expected text. +fn render() -> (String, String) { + let base = std::fs::read_to_string(path(BASE)).expect("the live-control session"); + let model = parse_session_json(&base).expect("the live-control session parses"); + let mut empty = model.clone(); + empty.sources.clear(); + empty.tracks.clear(); + empty.routes.clear(); + let document = canonical_session_json(&empty).expect("the empty session is canonical"); + // The committed document, parsed again, is what the page boots: its revision is the one the + // frame expects. + let revision = parse_session_json(&document).expect("parses").revision; + + let edits = vec![ + SessionEdit::UpsertSource { + source: model.sources[0].clone(), + }, + SessionEdit::UpsertTrack { + track: model.tracks[0].clone(), + }, + SessionEdit::UpsertRoute { + route: model.routes[0].clone(), + }, + ]; + let codec = ProtocolCodec::default(); + let mut bytes = vec![0_u8; codec.limits().max_frame_bytes]; + let length = codec + .encode_command_frame_into( + &TypedCommandFrame { + request_id: RequestId::new(1).expect("nonzero request"), + expected_revision: ExpectedRevision::Exact(SessionRevision(revision)), + payload: CommandPayload::SessionTransactionApply(&edits), + }, + &mut bytes, + ) + .expect("a transaction frame"); + bytes.truncate(length); + let hex = bytes.iter().fold(String::new(), |mut hex, byte| { + write!(hex, "{byte:02x}").expect("write to a string"); + hex + }); + let mut frame = String::new(); + writeln!( + frame, + "{{\n \"schema\": \"miso.host-web.empty-boot-frame\",\n \"revision\": \"{revision}\",\n \"hex\": \"{hex}\"\n}}" + ) + .expect("write to a string"); + (document, frame) +} + +/// Red if the codec or the live-control session changes without the empty session and the frame +/// the browser qualification sends following. +#[test] +fn the_empty_boot_fixtures_are_what_the_codec_and_the_session_write() { + let (document, frame) = render(); + for (name, expected) in [(EMPTY, document), (FRAME, frame)] { + let file = path(name); + if std::env::var_os("MISO_ENGINE_BLESS_FIXTURES").is_some() { + std::fs::write(&file, &expected).expect("write the fixture"); + continue; + } + let actual = std::fs::read_to_string(&file).unwrap_or_default(); + assert!( + actual == expected, + "{name} differs from what is derived; rerun with MISO_ENGINE_BLESS_FIXTURES=1 after a deliberate change" + ); + } +} From 6fda14f98532a00f364b84c67ed8ca81d86bc05c Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 13:58:55 +0000 Subject: [PATCH 08/12] Batch 13 docs: successor TBD31, designate the browser master strip after boot (from TBD30) Co-Authored-By: Claude Opus 5.5 --- ...ate-the-browser-master-strip-after-boot.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/ISSUE_SPECS/TBD31-designate-the-browser-master-strip-after-boot.md diff --git a/.github/ISSUE_SPECS/TBD31-designate-the-browser-master-strip-after-boot.md b/.github/ISSUE_SPECS/TBD31-designate-the-browser-master-strip-after-boot.md new file mode 100644 index 000000000..048e0303a --- /dev/null +++ b/.github/ISSUE_SPECS/TBD31-designate-the-browser-master-strip-after-boot.md @@ -0,0 +1,41 @@ +# Designate the browser master strip after boot + +Successor of TBD30 (an empty session boots), decision 17. Code anchors verified on the batch 13a +branch `codex/controls-batch-13aw`. Issue number: assigned at filing (written here as TBD31). +Feature sprint mode (owner, 2026-10-13): one implementer, normal tests, a batch review. + +## Product outcome + +An app that opens an empty mixer can still have a designated master strip: the designation is a +control the app sets (or changes) after boot, through the one core path, instead of a boot option +that must name a strip that exists at boot. + +## Context + +- The browser boot option `liveControlMasterTrackPlusOne` (with observation taps) designates a + master strip at boot; over an empty session the boot is refused `host.observation.master_track`, + because the strip does not exist yet (TBD30's decision record). +- Since TBD30 an empty session boots on both hosts and grows by structural transactions. + +## Decisions to take (the implementer proposes, the coordinator rules) + +- Where the designation lives: a session-scoped control row (a setting) the core owns and both + hosts read, or a host option that may name a strip added later (resolved at each plan's + preparation). +- What a designation naming a strip that does not exist yet does (refused before commit, or held + until the strip exists). + +## Objective gates + +1. Boot empty, add a track, designate it master after boot; the master's observation behaves as a + boot-time designation does today, on the browser (and on the C ABI if the row is core-owned, + with a parity row). +2. host-web and control-plane tests; the browser qualification. + +## Dependencies + +- TBD30. + +## Size + +Half a day. From 167fc1039c860ea845e8ec11ee7cb68bb64e8a2c Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 14:01:42 +0000 Subject: [PATCH 09/12] Make a send's follows_mute and a strip's matrix_mode non-automatable (#1632) The control registry declares an automation rule per row; follows_mute (a routing setting) and matrix_mode (a mode) declare an exclusion with its reason and reopen condition (root, 2026-10-14; owner-revisitable), so the one row rule refuses a stored automation on either as control.automation.target on both hosts. The SDK tables and the control-list fixture follow. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/author-session/SKILL.md | 2 +- ...ws-mute-and-matrix-mode-non-automatable.md | 26 ++++++++++++++ crates/controls/src/builtin_rows.rs | 3 +- crates/controls/src/descriptor.rs | 34 +++++++++++++++++-- crates/controls/src/effect_rows.rs | 4 ++- crates/controls/src/lib.rs | 4 ++- crates/controls/src/route.rs | 8 ++++- crates/controls/src/strip.rs | 8 ++++- crates/controls/tests/automation.rs | 20 +++++++++-- crates/controls/tests/list.rs | 34 +++++++++++++++++-- docs/CONTROL_PROTOCOL_REGISTRY.md | 2 +- docs/SESSION_SCHEMA_V1.md | 2 +- .../one-control-abstraction-2026-10-13.md | 5 ++- .../host-web/tests/fixtures/control-list.json | 10 +++--- .../miso-engine-v1-parameter-metadata.json | 4 +-- sdk/src/generated/controls.ts | 4 +-- 16 files changed, 144 insertions(+), 26 deletions(-) diff --git a/.claude/skills/author-session/SKILL.md b/.claude/skills/author-session/SKILL.md index 0bc3fe11d..ec877beed 100644 --- a/.claude/skills/author-session/SKILL.md +++ b/.claude/skills/author-session/SKILL.md @@ -119,7 +119,7 @@ left/right/both, while matrix IDs 7-10 accept `both` only, and only on a strip i `console` and `inserts`, `control` is the effect's parameter ID (`0` is its bypass). The session validator checks only that the owner, slot or insert exists; whether the row is automatable (not rebuild-only like `delay_samples` 11 or an EQ band's `enabled`, not derived, not a monitor or -setting row), takes the lane, unit and values is `control.address.unknown`, +setting row, and not a send's `follows_mute` (route row 7) or a strip's `matrix_mode` (own row 1): live controls that a ruling keeps out of automation, #1632), takes the lane, unit and values is `control.address.unknown`, `control.automation.target`, `control.inactive`, `control.lane` or `control.domain` at prepare-effects. A parameter does not have to be declared on the strip. Stored automation is inert today: it authors and round-trips, and renders nothing. diff --git a/.github/ISSUE_SPECS/1632-make-send-follows-mute-and-matrix-mode-non-automatable.md b/.github/ISSUE_SPECS/1632-make-send-follows-mute-and-matrix-mode-non-automatable.md index e1733a86f..c57d34ff0 100644 --- a/.github/ISSUE_SPECS/1632-make-send-follows-mute-and-matrix-mode-non-automatable.md +++ b/.github/ISSUE_SPECS/1632-make-send-follows-mute-and-matrix-mode-non-automatable.md @@ -75,3 +75,29 @@ and a mode, not performance values. ## Size Half a day. + +## Decision record: #1632 (batch 13a) + +Shipped as written (D1 to D4). `ControlDescriptor` gains `automation: AutomationRule` (`BASE` and every +other table: `Computed`); `automatable()` is the computed rule and `automation == Computed`. +`matrix_mode` ("a mode, not a performance value") and `follows_mute` ("a routing setting, not a +performance value") declare `AutomationRule::Excluded(&AutomationExclusion { reason, reopen: "an +owner request" })`. The row rule (`controls::automation_diagnostics`), preparation, the classifier +and `session-validator` need no new code: all four already refuse `!automatable()` as +`control.automation.target`. The wire check (an implication) is unchanged. + +Departure from the text: the exclusion is held by reference (`Excluded(&'static AutomationExclusion)`), +not inline. A listing copies one descriptor per row, and an inline pair of strings measurably grew +the live-only `control_command` heap (#1615 gate: first peak 81,451 against a ceiling of 78,000); +the reference costs one word per row (first peak 77,347, steady 72,588; ceilings 78,000 and 73,000 +hold). + +Gates and tests: `crates/controls/tests/automation.rs` +`the_row_decides_what_can_be_automated` (both rows refused at `.target`), +`crates/controls/tests/list.rs` `automatable_is_exactly_the_live_model_value_rows` (previous list +less the two names) and `follows_mute_and_matrix_mode_are_live_and_not_automatable`. Re-pins: the +host-web `tests/fixtures/control-list.json` (`automatable` of the five listed rows; reason +"#1632 makes follows_mute and matrix_mode non-automatable"), `sdk/assets/miso-engine-v1-parameter-metadata.json` +and `sdk/src/generated/controls.ts` (regenerated). No conformance corpus digest moved. No checked-in +automation fixture used either row. #1631 interplay: it still covers a pan entry on a strip +switched to matrix; a `matrix_mode` entry is now refused at the target before activity is judged. diff --git a/crates/controls/src/builtin_rows.rs b/crates/controls/src/builtin_rows.rs index eb5e64e6a..06784ac2e 100644 --- a/crates/controls/src/builtin_rows.rs +++ b/crates/controls/src/builtin_rows.rs @@ -11,7 +11,7 @@ use builtins::{ use session::{ChannelBuiltins, MatrixOrPan, StripRef}; use crate::ControlScope; -use crate::descriptor::{ControlDescriptor, DomainRule, Lanes, Motion}; +use crate::descriptor::{AutomationRule, ControlDescriptor, DomainRule, Lanes, Motion}; use crate::error::ControlError; use crate::lane::{Side, is_on, toggle}; use crate::strip::StripMut; @@ -108,6 +108,7 @@ pub(crate) fn shaped_descriptor( domain: row.domain, sample_rate, }, + automation: AutomationRule::Computed, } } diff --git a/crates/controls/src/descriptor.rs b/crates/controls/src/descriptor.rs index 0dbaa7e0f..d8c594619 100644 --- a/crates/controls/src/descriptor.rs +++ b/crates/controls/src/descriptor.rs @@ -73,6 +73,29 @@ pub enum DomainRule { Contract(&'static ParameterDescriptor), } +/// Whether a row's automatability is the computed rule alone or a ruled exclusion from it. +/// +/// "Automatable iff live" is computed ([`ControlDescriptor::automatable`]); a row that is live but +/// that a ruling keeps out of automation declares the exclusion here, with its reason and the +/// condition that reopens it, so no second rule hides in a table. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AutomationRule { + /// Automatable exactly when the row is a live model value that render reads. + Computed, + /// Never automatable, though live: a ruled exclusion. A reference keeps the descriptor, which a + /// listing copies once per row, one word wider. + Excluded(&'static AutomationExclusion), +} + +/// The reason a live row is kept out of automation, and what reopens the ruling. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AutomationExclusion { + /// Why the row is not a performance value a person automates. + pub reason: &'static str, + /// What reopens the ruling. + pub reopen: &'static str, +} + /// What one control is: everything a frontend or an agent needs to draw it, and everything the /// validator needs to check a write. The same type describes every control. /// @@ -119,14 +142,18 @@ pub struct ControlDescriptor { pub active: bool, /// Which function decides the domain (see [`DomainRule`]). pub domain: DomainRule, + /// Whether the computed automation rule stands or the row is a ruled exclusion from it. + pub automation: AutomationRule, } impl ControlDescriptor { - /// Whether an agent may automate this control. Computed, never declared: a live model value - /// that render reads (decision 14 rule 4 with decision 15, "automatable iff live"). + /// Whether an agent may automate this control. Computed: a live model value that render reads + /// (decision 14 rule 4 with decision 15, "automatable iff live"), unless the row declares a + /// ruled exclusion ([`AutomationRule::Excluded`], decision 17 C3). #[must_use] pub fn automatable(&self) -> bool { - self.class == ControlClass::Live + self.automation == AutomationRule::Computed + && self.class == ControlClass::Live && self.scope == ControlScope::Model && self.family != Family::Setting } @@ -233,4 +260,5 @@ pub(crate) const BASE: ControlDescriptor = ControlDescriptor { family: Family::Setting, active: true, domain: DomainRule::Range, + automation: AutomationRule::Computed, }; diff --git a/crates/controls/src/effect_rows.rs b/crates/controls/src/effect_rows.rs index e12e2da28..5c04d177b 100644 --- a/crates/controls/src/effect_rows.rs +++ b/crates/controls/src/effect_rows.rs @@ -22,7 +22,8 @@ use session::{ }; use crate::descriptor::{ - BASE, Choice, ControlDescriptor, ControlScope, DomainRule, Lanes, Motion, ruled_live_not_built, + AutomationRule, BASE, Choice, ControlDescriptor, ControlScope, DomainRule, Lanes, Motion, + ruled_live_not_built, }; use crate::error::ControlError; use crate::lane::{Side, is_on, toggle}; @@ -204,6 +205,7 @@ pub(crate) fn parameter_descriptor(parameter: &'static ParameterDescriptor) -> C family: Family::Effect, active: true, domain: DomainRule::Contract(parameter), + automation: AutomationRule::Computed, } } diff --git a/crates/controls/src/lib.rs b/crates/controls/src/lib.rs index 4ea34f1d2..8babcd72e 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -45,7 +45,9 @@ mod vca; mod view; pub use automation::{ControlDiagnostic, automation_diagnostics}; -pub use descriptor::{Choice, ControlDescriptor, ControlScope, DomainRule, Lanes, Motion, check}; +pub use descriptor::{ + AutomationRule, Choice, ControlDescriptor, ControlScope, DomainRule, Lanes, Motion, check, +}; pub use diff::{ ChangedControl, activity_changed, changed_controls, changed_rows, writes_only_its_row, }; diff --git a/crates/controls/src/route.rs b/crates/controls/src/route.rs index 94f86ad6c..77edf156c 100644 --- a/crates/controls/src/route.rs +++ b/crates/controls/src/route.rs @@ -10,7 +10,9 @@ use session::{ RouteDestination, SessionModel, StableId, }; -use crate::descriptor::{BASE, ControlDescriptor, Lanes, Motion}; +use crate::descriptor::{ + AutomationExclusion, AutomationRule, BASE, ControlDescriptor, Lanes, Motion, +}; use crate::error::ControlError; use crate::lane::{Side, toggle}; use crate::registry::{ControlRow, Resolved, Source, push_control}; @@ -177,6 +179,10 @@ pub(crate) static ROWS: [RouteRow; 8] = [ taper: Taper::Stepped, lattice: ParameterLattice::indices(), motion: Motion::Ramp(LiveRampRow::SendFollowsMute), + automation: AutomationRule::Excluded(&AutomationExclusion { + reason: "a routing setting, not a performance value", + reopen: "an owner request", + }), ..ROUTE_BASE }, // A route into the output carries `false` (#1218): the row does not apply. diff --git a/crates/controls/src/strip.rs b/crates/controls/src/strip.rs index ddc634799..a82a22b63 100644 --- a/crates/controls/src/strip.rs +++ b/crates/controls/src/strip.rs @@ -8,7 +8,9 @@ use session::{ }; use crate::builtin_rows; -use crate::descriptor::{BASE, Choice, ControlDescriptor, Lanes, Motion}; +use crate::descriptor::{ + AutomationExclusion, AutomationRule, BASE, Choice, ControlDescriptor, Lanes, Motion, +}; use crate::effect_rows::{self, Instance}; use crate::effective::EffectiveStrip; use crate::error::ControlError; @@ -87,6 +89,10 @@ pub(crate) static ROWS: [StripRow; 5] = [ choices: &MODE_CHOICES, motion: Motion::Ramp(LiveRampRow::Pan), family: Family::StripMatrix, + automation: AutomationRule::Excluded(&AutomationExclusion { + reason: "a mode, not a performance value", + reopen: "an owner request", + }), ..BASE }, value: StripValue::Stored { diff --git a/crates/controls/tests/automation.rs b/crates/controls/tests/automation.rs index d3799c7c5..1a01fa603 100644 --- a/crates/controls/tests/automation.rs +++ b/crates/controls/tests/automation.rs @@ -11,6 +11,8 @@ const FADER_DB: u32 = 5; const DELAY_SAMPLES: u32 = 11; const MATRIX_LL: u32 = 7; const SOLO: u32 = 2; +const MATRIX_MODE: u32 = 1; +const FOLLOWS_MUTE: u32 = 7; const PAN_WINDOW_SAMPLES: u32 = 3; const EFFECTIVE_FADER_DB: u32 = 64; const GAIN_DB: u32 = 1; @@ -62,11 +64,12 @@ fn eq_param(strip: &str, control: u32, lane: Lane) -> ControlAddress { } /// Gate 1: the row decides. A rebuild-only row, a derived row, the monitor row and a setting row -/// are refused at their target with `control.automation.target`; a send's gain, a VCA's offset, a +/// are refused at their target with `control.automation.target`, and so are a send's +/// `follows_mute` and a strip's `matrix_mode` (live, but ruled out of automation, #1632); a send's gain, a VCA's offset, a /// builtin fader and a block-rate effect parameter are accepted. /// -/// Red if automation can target a value that is not live, or cannot target a live send or VCA -/// value. +/// Red if automation can target a value that is not live or a ruled exclusion, or cannot target a +/// live send or VCA value. #[test] fn the_row_decides_what_can_be_automated() { let refused = [ @@ -95,6 +98,17 @@ fn the_row_decides_what_can_be_automated() { eq_param("vocal", BAND_ENABLED, Lane::Left), ParameterUnit::Linear, ), + // Live rows that a ruling keeps out of automation (#1632): a routing setting and a mode. + ( + "follows-mute", + route_own("send", FOLLOWS_MUTE), + ParameterUnit::Linear, + ), + ( + "matrix-mode", + strip_own("vocal", MATRIX_MODE, Lane::Both), + ParameterUnit::Linear, + ), ]; for (name, target, unit) in refused { assert_eq!( diff --git a/crates/controls/tests/list.rs b/crates/controls/tests/list.rs index e39719b54..9264ccf80 100644 --- a/crates/controls/tests/list.rs +++ b/crates/controls/tests/list.rs @@ -231,7 +231,6 @@ fn automatable_is_exactly_the_live_model_value_rows() { "hpf_hz", "lpf_hz", "gain_db", - "follows_mute", "offset_db", "fader_db", "mute", @@ -240,13 +239,44 @@ fn automatable_is_exactly_the_live_model_value_rows() { "matrix_lr", "matrix_rl", "matrix_rr", - "matrix_mode", ] .into_iter() .collect(); assert_eq!(names, expected); } +/// #1632. Red if `follows_mute` or `matrix_mode` is automatable, or stops being a live model row: +/// each is a live control that a ruling keeps out of automation ("a routing setting" and "a mode"). +#[test] +fn follows_mute_and_matrix_mode_are_live_and_not_automatable() { + let model = fixture(); + let mut seen = BTreeSet::new(); + for row in list(&model, &controls::MonitorOverlay::default(), RATE) { + let d = &row.descriptor; + if d.name != "follows_mute" && d.name != "matrix_mode" { + continue; + } + seen.insert(d.name); + assert!(!d.automatable(), "{}", d.name); + // A route into the output folds `follows_mute` away (#1218); the send into the submix is + // the live row. + if matches!(&row.address.owner, Owner::Route(id) if id.as_str() != "send") { + continue; + } + assert_eq!(d.class, ControlClass::Live, "{}", d.name); + assert_eq!(d.scope, controls::ControlScope::Model, "{}", d.name); + assert!( + matches!( + d.automation, + controls::AutomationRule::Excluded(exclusion) if exclusion.reopen == "an owner request" + ), + "{}", + d.name + ); + } + assert_eq!(seen, BTreeSet::from(["follows_mute", "matrix_mode"])); +} + /// Gate 3. Red if a row lacks a name, a reason's reopening condition, its choices, or a default /// and committed value inside its own domain. #[test] diff --git a/docs/CONTROL_PROTOCOL_REGISTRY.md b/docs/CONTROL_PROTOCOL_REGISTRY.md index 279f60708..2a4fdedd7 100644 --- a/docs/CONTROL_PROTOCOL_REGISTRY.md +++ b/docs/CONTROL_PROTOCOL_REGISTRY.md @@ -54,7 +54,7 @@ Snapshot continuations and diagnostic pages use an exact revision after an any-r **Entry** (`ControlEntry`): `1:handle U32 R`, `2:row-kind U8 R` (owner header `1`, section header `2`, control `3`), `3:address MESSAGE R`. A header row adds `4:header-kind U8` (owner: track `1`, submix `2`, route `3`, VCA `4`, session `5`, and it must agree with the address's owner kind; section: the section code), `5:name UTF8`, `6:effect UTF8 O` (a console or inserts section header only), and for a route owner header `7:source UTF8`, `8:destination UTF8`, `9:tap U8` (the route tap codes below). A control row adds `10:name`, `11:display-name`, `12:display-unit` (UTF8), `13:unit U8` (dB/Hz/ms/samples/linear/ratio `1..6`), `14:kind U8` (continuous `1`, toggle `2`, enumeration `3`), `15:minimum`, `16:maximum`, `17:disabled-value O`, `18:default` (F32), `19:taper U8` (linear `1`, logarithmic `2`, exponential `3`, decibel `4`, stepped `5`), `20:step F32`, `21:precision U8`, `22:ladder PACKED_F32`, `23:choice MESSAGE O*` (value `1:F32`, label `2:UTF8`), `24:ramp-row U8 O` (the live-ramp rows in declaration order: fader `1`, input trim `2`, send gain `3`, VCA offset `4`, link glide `5`, mute `6`, solo `7`, polarity invert `8`, send mute `9`, send follows-mute `10`, VCA mute `11`, bypass crossfade `12`, pan `13`, matrix `14`, send matrix `15`; absent means a fixed ramp), `25:default-ramp-samples U32`, `26:class U8` (live `1`, rebuild-only `2`, derived `3`), `27:reason U8 O` (new memory `1`, new graph `2`, processing order `3`, latency `4`, glitch `5`, optimisation `6`, correctness `7`, ruled live not built `8`), `28:reason-issue U32 O`, `29:reopen UTF8 O`, `30:scope U8` (model `1`, monitor `2`), `31:automatable BOOL`, `32:family U8` (strip level `1`, strip matrix `2`, strip input `3`, route `4`, effect `5`, setting `6`), `33:active BOOL`, `34:value F32`. Header-only and control-only fields are optional on the wire and required or refused by the row kind: a header with a control field, a control row with a header field, a missing required field or a code outside its table is `INVALID_FIELD`. -A control row's own rules: all numbers are finite and `minimum <= maximum`; choices are present exactly for an enumeration, unique, and hold the default and the value; `reason` and `reopen` are present exactly when the class is rebuild-only, and `reason-issue` exactly with reason `8`; a control is automatable only if it is live, in the model scope and not a setting. +A control row's own rules: all numbers are finite and `minimum <= maximum`; choices are present exactly for an enumeration, unique, and hold the default and the value; `reason` and `reopen` are present exactly when the class is rebuild-only, and `reason-issue` exactly with reason `8`; a control is automatable only if it is live, in the model scope and not a setting, and the wire check is that implication only: a live row that a ruling excludes from automation is a valid row with `automatable` false. Two rows are excluded (#1632, decision 17 C3): a send's `follows_mute` ("a routing setting, not a performance value") and a strip's `matrix_mode` ("a mode, not a performance value"); an owner request reopens either. **Pages.** The list page is `1:revision U64`, `2:structure-epoch U64`, `3:last-handle U32` (the last row's handle, or the request's cursor on an empty page), `4:eof BOOL`, `5:ControlEntry MESSAGE R*` (at most 256, ascending). The state page has the same first four fields, then count `5:U16`, stride `6:U16=8` and records `7:BYTES`: `{handle:u32, value:f32}` per control row of the covered handles, ascending; `last-handle` is the cursor for the next request and may follow the last record (a header row is covered and has no record). A record array whose width, count, order or values break these rules is refused. Handles are revision-scoped; a record's state flags no longer exist, because a value is always valid. diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index a04fe1073..e3583e261 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -271,7 +271,7 @@ owner uses the `own` section (`schema.invalid_enum` at `target.section` otherwis 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 or a setting), `control.inactive` (the owner's shape +(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 addresses that submix's entry for the slot (#1202). A session does not have to state a parameter diff --git a/docs/rulings/one-control-abstraction-2026-10-13.md b/docs/rulings/one-control-abstraction-2026-10-13.md index b05015c4d..7e58ecd6b 100644 --- a/docs/rulings/one-control-abstraction-2026-10-13.md +++ b/docs/rulings/one-control-abstraction-2026-10-13.md @@ -114,7 +114,10 @@ The stored automation target `{entity_id, rack, effect_id, parameter_id, channel - `scope`: `model` (in the session document) or `monitor` (solo: host-local, never saved, never advances the revision; #1057 item 8); - `automatable`: **computed, not declared**: `class == live && scope == model && family != setting` - (decision 14 rule 4 with D15's "automatable iff live"); + (decision 14 rule 4 with D15's "automatable iff live"), less one declared exclusion: a row may + carry `AutomationRule::Excluded { reason, reopen }`. Root's ruling of 2026-10-14 (#1632, + revisitable by the owner) excludes a send's `follows_mute` ("a routing setting") and a strip's + `matrix_mode` ("a mode"); both stay live controls; - `family`: the render family of C6; - `active`: false for a row that the owner's present shape does not use (the matrix rows of a strip in pan mode, and the reverse). The row set of a session changes only with its structure. diff --git a/hosts/host-web/tests/fixtures/control-list.json b/hosts/host-web/tests/fixtures/control-list.json index d1e57da82..c838a61b2 100644 --- a/hosts/host-web/tests/fixtures/control-list.json +++ b/hosts/host-web/tests/fixtures/control-list.json @@ -2,7 +2,7 @@ "sampleRate": 48000, "session": "{\n \"schema_version\": 1,\n \"session_id\": \"web-browser-one-track\",\n \"revision\": \"1\",\n \"sample_rate_hz\": 48000,\n \"quantum_frames\": 128,\n \"render_profile\": {\n \"id\": \"native\",\n \"mode\": \"single_thread\"\n },\n \"output_profile\": {\n \"id\": \"main\",\n \"channels\": 2,\n \"sample_format\": \"f32_planar\"\n },\n \"sources\": [\n {\n \"id\": \"src-t1\",\n \"content\": \"blake3:18e5038d588b861a1754abcd0e0cc0a943fe983d4ac112232a972a4d9b5c28c5\",\n \"channels\": 2,\n \"bit_depth\": \"32f\",\n \"frames\": \"9216\"\n },\n {\n \"id\": \"src-t2\",\n \"content\": \"blake3:18e5038d588b861a1754abcd0e0cc0a943fe983d4ac112232a972a4d9b5c28c5\",\n \"channels\": 2,\n \"bit_depth\": \"32f\",\n \"frames\": \"9216\"\n },\n {\n \"id\": \"src-t3\",\n \"content\": \"blake3:18e5038d588b861a1754abcd0e0cc0a943fe983d4ac112232a972a4d9b5c28c5\",\n \"channels\": 2,\n \"bit_depth\": \"32f\",\n \"frames\": \"9216\"\n }\n ],\n \"console\": {\n \"pre_insert\": [\n {\n \"slot\": \"pre-comp\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.compressor\"\n },\n \"quality\": \"normal\",\n \"link_mode\": \"dual_mono\"\n }\n ],\n \"post_insert\": [\n {\n \"slot\": \"post-clip\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.soft-clip\"\n },\n \"quality\": \"normal\",\n \"link_mode\": \"dual_mono\"\n }\n ]\n },\n \"tracks\": [\n {\n \"id\": \"t1\",\n \"source_id\": \"src-t1\",\n \"left_source_channel\": 0,\n \"right_source_channel\": 1,\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"pre-comp\",\n \"bypass\": false,\n \"params\": []\n },\n {\n \"slot\": \"post-clip\",\n \"bypass\": false,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.parametric-eq\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [\n {\n \"parameter_id\": 1,\n \"channel\": \"both\",\n \"unit\": \"linear\",\n \"value\": 1.0\n },\n {\n \"parameter_id\": 2,\n \"channel\": \"both\",\n \"unit\": \"linear\",\n \"value\": 2.0\n },\n {\n \"parameter_id\": 3,\n \"channel\": \"both\",\n \"unit\": \"hz\",\n \"value\": 1000.0\n },\n {\n \"parameter_id\": 4,\n \"channel\": \"both\",\n \"unit\": \"db\",\n \"value\": 0.0\n }\n ],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"comp\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.compressor\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": -2.0,\n \"right_db\": -2.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"pan\": {\n \"left\": -0.8,\n \"right\": 0.6,\n \"smoothing_samples\": 200\n }\n },\n {\n \"id\": \"t2\",\n \"source_id\": \"src-t2\",\n \"left_source_channel\": 0,\n \"right_source_channel\": 1,\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"pre-comp\",\n \"bypass\": false,\n \"params\": []\n },\n {\n \"slot\": \"post-clip\",\n \"bypass\": false,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"gate\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.gate-expander\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [\n {\n \"parameter_id\": 6,\n \"channel\": \"both\",\n \"unit\": \"milliseconds\",\n \"value\": 0.0\n }\n ],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"clip\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.soft-clip\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"shaper\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.transient-shaper\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": -3.0,\n \"right_db\": -3.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"pan\": {\n \"left\": -0.5,\n \"right\": 0.4,\n \"smoothing_samples\": 0\n }\n },\n {\n \"id\": \"t3\",\n \"source_id\": \"src-t3\",\n \"left_source_channel\": 0,\n \"right_source_channel\": 1,\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"pre-comp\",\n \"bypass\": false,\n \"params\": []\n },\n {\n \"slot\": \"post-clip\",\n \"bypass\": false,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"multi\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.multiband-compressor\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"limiter\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.true-peak-limiter\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"delay\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.delay\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": -4.0,\n \"right_db\": -4.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"matrix\": {\n \"ll\": 0.9,\n \"lr\": 0.1,\n \"rl\": 0.2,\n \"rr\": 0.8,\n \"smoothing_samples\": 0\n }\n }\n ],\n \"submixes\": [\n {\n \"id\": \"b\",\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"pre-comp\",\n \"bypass\": true,\n \"params\": []\n },\n {\n \"slot\": \"post-clip\",\n \"bypass\": true,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": []\n },\n \"fader\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0,\n \"smoothing_samples\": 0\n }\n }\n ],\n \"vcas\": [\n {\n \"id\": \"v\",\n \"fader\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"members\": [\n \"t2\"\n ]\n }\n ],\n \"control_smoothing\": {\n \"kind\": \"default\"\n },\n \"outputs\": [\n {\n \"id\": \"main-out\"\n }\n ],\n \"routes\": [\n {\n \"id\": \"b-main\",\n \"source\": {\n \"kind\": \"submix\",\n \"submix_id\": \"b\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n },\n {\n \"id\": \"t1-b\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"t1\",\n \"tap\": \"pre_fader\"\n },\n \"destination\": {\n \"kind\": \"submix_input\",\n \"submix_id\": \"b\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": true\n },\n {\n \"id\": \"t1-main\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"t1\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n },\n {\n \"id\": \"t2-main\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"t2\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n },\n {\n \"id\": \"t3-main\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"t3\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n }\n ],\n \"automation\": []\n}\n", "rows": [ - { "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": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 2 }, + { "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": 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 }, @@ -128,7 +128,7 @@ { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "right", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "left", "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": "t1" }, "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": "t2" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "matrix_mode", "class": "live", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 2 }, + { "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": 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 }, @@ -208,7 +208,7 @@ { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "right", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "left", "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": "t2" }, "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": "t3" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "matrix_mode", "class": "live", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 2 }, + { "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": 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 }, @@ -299,7 +299,7 @@ { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "right", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "left", "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": "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": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 2 }, + { "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": 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 }, @@ -362,7 +362,7 @@ { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "matrix_lr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 5, "lane": "both", "name": "matrix_rl", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 6, "lane": "both", "name": "matrix_rr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 7, "lane": "both", "name": "follows_mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 0 }, + { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 7, "lane": "both", "name": "follows_mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 0 }, { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "gain_db", "class": "rebuildOnly", "kind": "continuous", "unit": "db", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, diff --git a/sdk/assets/miso-engine-v1-parameter-metadata.json b/sdk/assets/miso-engine-v1-parameter-metadata.json index 87084b465..233aa5c8d 100644 --- a/sdk/assets/miso-engine-v1-parameter-metadata.json +++ b/sdk/assets/miso-engine-v1-parameter-metadata.json @@ -1272,7 +1272,7 @@ "lanes": [{ "name": "left", "code": 1 }, { "name": "right", "code": 2 }, { "name": "both", "code": 3 }] }, "stripOwn": [ - { "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": true }, + { "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": 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 }, @@ -1299,7 +1299,7 @@ { "control": 4, "name": "matrix_lr", "displayName": "Send Matrix Left from Right", "displayUnit": "", "unit": "linear", "kind": "continuous", "minimum": -1.0, "maximum": 1.0, "disabledValue": null, "default": 0.0, "taper": "linear", "step": { "unit": "absolute", "size": "0.01", "precision": 2, "ladder": { "xs": 1, "sm": 3, "md": 5, "lg": 10, "xl": 30 } }, "choices": [], "ramp": "sendMatrix", "scope": "model", "family": "route", "lanes": "shared", "intoSubmix": { "class": "live", "reason": null, "reasonIssue": null, "reopen": null, "automatable": true, "active": true }, "intoOutput": { "class": "rebuildOnly", "reason": "optimisation", "reasonIssue": null, "reopen": "An owner product request together with live route lanes in the epilogue fold (decision 13, O9 and O4), or a real-path measurement showing that losing the fold costs little.", "automatable": false, "active": true } }, { "control": 5, "name": "matrix_rl", "displayName": "Send Matrix Right from Left", "displayUnit": "", "unit": "linear", "kind": "continuous", "minimum": -1.0, "maximum": 1.0, "disabledValue": null, "default": 0.0, "taper": "linear", "step": { "unit": "absolute", "size": "0.01", "precision": 2, "ladder": { "xs": 1, "sm": 3, "md": 5, "lg": 10, "xl": 30 } }, "choices": [], "ramp": "sendMatrix", "scope": "model", "family": "route", "lanes": "shared", "intoSubmix": { "class": "live", "reason": null, "reasonIssue": null, "reopen": null, "automatable": true, "active": true }, "intoOutput": { "class": "rebuildOnly", "reason": "optimisation", "reasonIssue": null, "reopen": "An owner product request together with live route lanes in the epilogue fold (decision 13, O9 and O4), or a real-path measurement showing that losing the fold costs little.", "automatable": false, "active": true } }, { "control": 6, "name": "matrix_rr", "displayName": "Send Matrix Right from Right", "displayUnit": "", "unit": "linear", "kind": "continuous", "minimum": -1.0, "maximum": 1.0, "disabledValue": null, "default": 1.0, "taper": "linear", "step": { "unit": "absolute", "size": "0.01", "precision": 2, "ladder": { "xs": 1, "sm": 3, "md": 5, "lg": 10, "xl": 30 } }, "choices": [], "ramp": "sendMatrix", "scope": "model", "family": "route", "lanes": "shared", "intoSubmix": { "class": "live", "reason": null, "reasonIssue": null, "reopen": null, "automatable": true, "active": true }, "intoOutput": { "class": "rebuildOnly", "reason": "optimisation", "reasonIssue": null, "reopen": "An owner product request together with live route lanes in the epilogue fold (decision 13, O9 and O4), or a real-path measurement showing that losing the fold costs little.", "automatable": false, "active": true } }, - { "control": 7, "name": "follows_mute", "displayName": "Send Follows 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": "sendFollowsMute", "scope": "model", "family": "route", "lanes": "shared", "intoSubmix": { "class": "live", "reason": null, "reasonIssue": null, "reopen": null, "automatable": true, "active": true }, "intoOutput": { "class": "rebuildOnly", "reason": "optimisation", "reasonIssue": null, "reopen": "An owner product request together with live route lanes in the epilogue fold (decision 13, O9 and O4), or a real-path measurement showing that losing the fold costs little.", "automatable": false, "active": false } }, + { "control": 7, "name": "follows_mute", "displayName": "Send Follows 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": "sendFollowsMute", "scope": "model", "family": "route", "lanes": "shared", "intoSubmix": { "class": "live", "reason": null, "reasonIssue": null, "reopen": null, "automatable": false, "active": true }, "intoOutput": { "class": "rebuildOnly", "reason": "optimisation", "reasonIssue": null, "reopen": "An owner product request together with live route lanes in the epilogue fold (decision 13, O9 and O4), or a real-path measurement showing that losing the fold costs little.", "automatable": false, "active": false } }, { "control": 64, "name": "effective_gate", "displayName": "Effective Gate", "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": "route", "lanes": "perLane", "intoSubmix": { "class": "derived", "reason": null, "reasonIssue": null, "reopen": null, "automatable": false, "active": true }, "intoOutput": { "class": "derived", "reason": null, "reasonIssue": null, "reopen": null, "automatable": false, "active": true } } ], "vcaOwn": [ diff --git a/sdk/src/generated/controls.ts b/sdk/src/generated/controls.ts index 0f01ebade..03bc1aafb 100644 --- a/sdk/src/generated/controls.ts +++ b/sdk/src/generated/controls.ts @@ -112,7 +112,7 @@ export const CONTROLS = deepFreeze( "reason": null, "reasonIssue": null, "reopen": null, - "automatable": true + "automatable": false }, { "control": 2, @@ -999,7 +999,7 @@ export const CONTROLS = deepFreeze( "reason": null, "reasonIssue": null, "reopen": null, - "automatable": true, + "automatable": false, "active": true }, "intoOutput": { From 8fa4543f4731d91aaa4bf6f132c500e73eed9f12 Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 15:12:47 +0000 Subject: [PATCH 10/12] Batch 13a integration: the simd128 boot memory is one page smaller after #1632 check-browser-expected-resources measured the simd128 initial and before-dispose memoryBytes at 2,818,048 (was 2,883,584, one 64 KiB page less). Bisected to 167fc1039 (#1632); the stream did not run this gate. The set-control timeline row stays 2,883,584. A smaller boot is benign; re-pinned from the measurement. Co-Authored-By: Claude Opus 5.5 --- hosts/host-web/tests/browser-v1/expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hosts/host-web/tests/browser-v1/expected.json b/hosts/host-web/tests/browser-v1/expected.json index b86b962f4..7c73d912b 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": 2883584 + "memoryBytes": 2818048 }, "beforeDisposeStatus": { "state": 2, @@ -46,7 +46,7 @@ "quantumFrames": 128, "nextAbsoluteSample": "384", "renderedQuanta": "3", - "memoryBytes": 2883584 + "memoryBytes": 2818048 }, "resources": { "sampleRateHz": 48000, From de4618e95b5a6647e9add49312bf891d3ab2da57 Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 15:16:34 +0000 Subject: [PATCH 11/12] Number the batch 13a placeholders: TBD29 to TBD31 are #1681 to #1683 Co-Authored-By: Claude Opus 5.5 --- ...681-refuse-a-source-with-more-than-two-channels.md} | 2 +- ....md => 1682-boot-an-empty-session-on-both-hosts.md} | 4 ++-- ...3-designate-the-browser-master-strip-after-boot.md} | 10 +++++----- crates/capi/tests/empty_boot.rs | 2 +- crates/host-core/tests/empty_boot.rs | 2 +- docs/REALTIME_DEPENDENCY_POLICY.md | 2 +- hosts/host-web/qualification/qualification.js | 4 ++-- hosts/host-web/qualification/run.mjs | 4 ++-- hosts/host-web/qualification/sdk-response-entry.ts | 2 +- hosts/host-web/src/lib.rs | 6 +++--- hosts/host-web/tests/control_parity.rs | 2 +- hosts/host-web/tests/empty_boot.rs | 2 +- hosts/host-web/tests/empty_boot_fixture.rs | 2 +- 13 files changed, 22 insertions(+), 22 deletions(-) rename .github/ISSUE_SPECS/{TBD29-refuse-a-source-with-more-than-two-channels.md => 1681-refuse-a-source-with-more-than-two-channels.md} (99%) rename .github/ISSUE_SPECS/{TBD30-boot-an-empty-session-on-both-hosts.md => 1682-boot-an-empty-session-on-both-hosts.md} (98%) rename .github/ISSUE_SPECS/{TBD31-designate-the-browser-master-strip-after-boot.md => 1683-designate-the-browser-master-strip-after-boot.md} (86%) diff --git a/.github/ISSUE_SPECS/TBD29-refuse-a-source-with-more-than-two-channels.md b/.github/ISSUE_SPECS/1681-refuse-a-source-with-more-than-two-channels.md similarity index 99% rename from .github/ISSUE_SPECS/TBD29-refuse-a-source-with-more-than-two-channels.md rename to .github/ISSUE_SPECS/1681-refuse-a-source-with-more-than-two-channels.md index 53cb5fcce..8d05064fb 100644 --- a/.github/ISSUE_SPECS/TBD29-refuse-a-source-with-more-than-two-channels.md +++ b/.github/ISSUE_SPECS/1681-refuse-a-source-with-more-than-two-channels.md @@ -1,6 +1,6 @@ # Refuse a source with more than two channels -Owner answer (2026-10-15): sources are mono or stereo only. Decision 17 programme. Code anchors verified on `4be10cd67` (PR #1680). Issue number: assigned at filing (written here as TBD29). Feature sprint mode (owner, 2026-10-13): one implementer, normal tests, a batch review. +Owner answer (2026-10-15): sources are mono or stereo only. Decision 17 programme. Code anchors verified on `4be10cd67` (PR #1680). Issue number: assigned at filing (written here as #1681). Feature sprint mode (owner, 2026-10-13): one implementer, normal tests, a batch review. ## Product outcome diff --git a/.github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md b/.github/ISSUE_SPECS/1682-boot-an-empty-session-on-both-hosts.md similarity index 98% rename from .github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md rename to .github/ISSUE_SPECS/1682-boot-an-empty-session-on-both-hosts.md index a9e0c03cf..ac3d1624d 100644 --- a/.github/ISSUE_SPECS/TBD30-boot-an-empty-session-on-both-hosts.md +++ b/.github/ISSUE_SPECS/1682-boot-an-empty-session-on-both-hosts.md @@ -1,7 +1,7 @@ # Boot an empty session on both hosts Owner answer (2026-10-15): an app opens an empty mixer and adds stems through structural edits. -Decision 17 programme. Code anchors verified on `4be10cd67` (PR #1680). Issue number: assigned at filing (written here as TBD30). Feature sprint mode (owner, 2026-10-13): one implementer, normal tests, a batch review. +Decision 17 programme. Code anchors verified on `4be10cd67` (PR #1680). Issue number: assigned at filing (written here as #1682). Feature sprint mode (owner, 2026-10-13): one implementer, normal tests, a batch review. ## Product outcome @@ -60,7 +60,7 @@ What shipped: empty boot needed no other change, and no host adapter has a special path (D1). Every boot-time size that depends on the strip count was exercised at zero by the gates below and needed no fix (D2); the successor path grows from empty to one strip and shrinks back. -- From TBD29: `HostPrepareCaps::maximum_source_channels` and its `host.source.channels` check are +- From #1681: `HostPrepareCaps::maximum_source_channels` and its `host.source.channels` check are deleted (the validator rule is the one limit), with its test `source_channel_cap_is_optional`, its `MUTATIONS.md` row M-06 and the stale doc line; callers dropped the field. diff --git a/.github/ISSUE_SPECS/TBD31-designate-the-browser-master-strip-after-boot.md b/.github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md similarity index 86% rename from .github/ISSUE_SPECS/TBD31-designate-the-browser-master-strip-after-boot.md rename to .github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md index 048e0303a..d744b11df 100644 --- a/.github/ISSUE_SPECS/TBD31-designate-the-browser-master-strip-after-boot.md +++ b/.github/ISSUE_SPECS/1683-designate-the-browser-master-strip-after-boot.md @@ -1,7 +1,7 @@ # Designate the browser master strip after boot -Successor of TBD30 (an empty session boots), decision 17. Code anchors verified on the batch 13a -branch `codex/controls-batch-13aw`. Issue number: assigned at filing (written here as TBD31). +Successor of #1682 (an empty session boots), decision 17. Code anchors verified on the batch 13a +branch `codex/controls-batch-13aw`. Issue number: assigned at filing (written here as #1683). Feature sprint mode (owner, 2026-10-13): one implementer, normal tests, a batch review. ## Product outcome @@ -14,8 +14,8 @@ that must name a strip that exists at boot. - The browser boot option `liveControlMasterTrackPlusOne` (with observation taps) designates a master strip at boot; over an empty session the boot is refused `host.observation.master_track`, - because the strip does not exist yet (TBD30's decision record). -- Since TBD30 an empty session boots on both hosts and grows by structural transactions. + because the strip does not exist yet (#1682's decision record). +- Since #1682 an empty session boots on both hosts and grows by structural transactions. ## Decisions to take (the implementer proposes, the coordinator rules) @@ -34,7 +34,7 @@ that must name a strip that exists at boot. ## Dependencies -- TBD30. +- #1682. ## Size diff --git a/crates/capi/tests/empty_boot.rs b/crates/capi/tests/empty_boot.rs index d5d8326f3..fe5bd935b 100644 --- a/crates/capi/tests/empty_boot.rs +++ b/crates/capi/tests/empty_boot.rs @@ -1,4 +1,4 @@ -//! TBD30: a session with no source and no track boots through the C ABI (owner answer of +//! #1682: a session with no source and no track boots through the C ABI (owner answer of //! 2026-10-15), and a structural transaction then adds a stereo stem with its track. //! //! * **Boot.** The empty session (only the output and one declared console slot) compiles. Its diff --git a/crates/host-core/tests/empty_boot.rs b/crates/host-core/tests/empty_boot.rs index 471b010ee..c20485c60 100644 --- a/crates/host-core/tests/empty_boot.rs +++ b/crates/host-core/tests/empty_boot.rs @@ -1,4 +1,4 @@ -//! TBD30 D1 and D2: a session with no source and no track prepares through the shared facade and +//! #1682 D1 and D2: a session with no source and no track prepares through the shared facade and //! renders silence, with no special path for a host adapter. //! //! The browser and the C ABI both prepare through `prepare_host_runtime`, so this is the one core diff --git a/docs/REALTIME_DEPENDENCY_POLICY.md b/docs/REALTIME_DEPENDENCY_POLICY.md index e23eb2c10..ac8d742d2 100644 --- a/docs/REALTIME_DEPENDENCY_POLICY.md +++ b/docs/REALTIME_DEPENDENCY_POLICY.md @@ -203,7 +203,7 @@ no reserved (or `RES0`) bit, and names the guard or write-back that restores the C calls themselves (engine create and destroy, compile, command submit, session and plan destroy), each with the handle and storage contract stated beside it; it installs no allocator and calls no render entry. -- `crates/capi/tests/empty_boot.rs` (TBD30): the exported-C evidence that a session with no source +- `crates/capi/tests/empty_boot.rs` (#1682): the exported-C evidence that a session with no source and no track boots, renders silence and grows by a structural transaction, with zero allocator calls in the render entry. Its unsafe is the C calls themselves (engine create and destroy, compile, command submit, service, event dequeue, source seek and submit, plan resource and diff --git a/hosts/host-web/qualification/qualification.js b/hosts/host-web/qualification/qualification.js index 7aa8cbd55..359b76887 100644 --- a/hosts/host-web/qualification/qualification.js +++ b/hosts/host-web/qualification/qualification.js @@ -506,7 +506,7 @@ async function runStructuralApplyQualification(createHost, sessionDocument) { }; } -/// TBD30: an empty session in a real browser. The live-control session with no source, no track and +/// #1682: an empty session in a real browser. The live-control session with no source, no track and /// no route boots and renders silence, and one structural transaction adds the source, the track /// and the route back, after which the fed PCM comes out of the output unchanged. /// @@ -1001,7 +1001,7 @@ export async function runQualification() { // Issue #143 E12: the live-control session plus one compressor, so an armed tap has a real // reduction. const observationDocument = new TextEncoder().encode(await observationResponse.text()); - // TBD30: the live-control session with no source, track or route, and the frame that adds them. + // #1682: the live-control session with no source, track or route, and the frame that adds them. const emptyDocument = new TextEncoder().encode(await emptyResponse.text()); const emptyFrame = await emptyFrameResponse.json(); const simd128 = WebAssembly.validate(SIMD128_PROBE); diff --git a/hosts/host-web/qualification/run.mjs b/hosts/host-web/qualification/run.mjs index f1558bebc..15cc8d0ec 100644 --- a/hosts/host-web/qualification/run.mjs +++ b/hosts/host-web/qualification/run.mjs @@ -28,7 +28,7 @@ const MUTATIONS = [ // moved the output off the declared delay, or whose meter stream was not the successor's. "structural-apply-path", "structural-apply-early", "structural-apply-output", "structural-apply-meter", - // TBD30: an empty session that did not boot silent and empty, one whose first stem was not a + // #1682: an empty session that did not boot silent and empty, one whose first stem was not a // rebuild heard unchanged, and one whose meter stream did not follow the strip count. "empty-boot-silence", "empty-boot-shape", "empty-boot-first-stem", "empty-boot-meter", // Issue #143 E12: the observation row. `observation-armed` is the eval's named red mutation -- @@ -249,7 +249,7 @@ function validate(browserName, result) { && Number.isSafeInteger(structural.structureEpochs[0]) && structural.structureEpochs[0] >= 1, `the meter stream after the swap was not the successor's with one structure epoch: ${JSON.stringify(structural)}`); - // TBD30: an empty session in a real browser. It boots and renders exact silence with no source + // #1682: an empty session in a real browser. It boots and renders exact silence with no source // and no strip in the session map and the control list, the meter stream is the master's two // peaks alone, and one structural transaction adds the source, the track and the route: the fed // PCM then comes out unchanged from the swap block, the applied revision was not early, and the diff --git a/hosts/host-web/qualification/sdk-response-entry.ts b/hosts/host-web/qualification/sdk-response-entry.ts index 3f1652564..b1b46a42a 100644 --- a/hosts/host-web/qualification/sdk-response-entry.ts +++ b/hosts/host-web/qualification/sdk-response-entry.ts @@ -2498,7 +2498,7 @@ export async function applyDelayThroughSdk( } /** - * Send one structural command frame the protocol codec wrote (TBD30: the frame that adds the first + * Send one structural command frame the protocol codec wrote (#1682: the frame that adds the first * stem) through the SDK's endpoint for the host. The SDK has no encoder for a structural edit, and * the engine's replay cache refuses a request ID twice, so the frame takes the next ID of the * host's one allocator and the committed revision, the two header words that depend on the engine. diff --git a/hosts/host-web/src/lib.rs b/hosts/host-web/src/lib.rs index 13c9dc9ff..aa3845f08 100644 --- a/hosts/host-web/src/lib.rs +++ b/hosts/host-web/src/lib.rs @@ -3086,7 +3086,7 @@ impl AudioWorkletEngineHost { } /// Whether the host was prepared with a meter window (issue #137 D2). It is the - /// configuration's word, not the plan's strip count: a session with no strip (TBD30) is + /// configuration's word, not the plan's strip count: a session with no strip (#1682) is /// metered by the master alone, and takes the lease so that the first strip's meters are /// delivered from the block that adopts them. #[must_use] @@ -3583,7 +3583,7 @@ impl AudioWorkletEngineHost { let mut folded_end = 0_u64; let mut folded = false; if meter_count == 0 { - // A plan with no strip (TBD30) has no strip window to pair the master with, so the + // A plan with no strip (#1682) has no strip window to pair the master with, so the // master's own windows are the stream: the frame is the master's three words. let Some(front) = ready .master_windows @@ -6246,7 +6246,7 @@ fn compile_ready( report.largest_named_allocation_bytes = report .largest_named_allocation_bytes .max(report.largest_bridge_allocation_bytes); - // The master windows queue as deep as a strip meter's. A session with no strip (TBD30) has no + // The master windows queue as deep as a strip meter's. A session with no strip (#1682) has no // meter to size it from, and still meters its master when a window is configured: the // ring is the boot's, so it must already hold the depth the first strip will use. let master_capacity = companions.meters().first().map_or_else( diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 44c2c546b..11ebb200d 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -2500,7 +2500,7 @@ fn a_mode_switch_under_a_stored_pan_entry_is_refused_alike_on_both_hosts() { } // --------------------------------------------------------------------------------------------- -// An empty session boots and grows by a structural transaction (TBD30) +// An empty session boots and grows by a structural transaction (#1682) // --------------------------------------------------------------------------------------------- /// Both hosts boot a session with no source and no track (one console slot stays declared), render diff --git a/hosts/host-web/tests/empty_boot.rs b/hosts/host-web/tests/empty_boot.rs index 8890186d7..b28cfaf48 100644 --- a/hosts/host-web/tests/empty_boot.rs +++ b/hosts/host-web/tests/empty_boot.rs @@ -1,4 +1,4 @@ -//! TBD30 (browser part): a session with no source and no track boots on the browser module, and a +//! #1682 (browser part): a session with no source and no track boots on the browser module, and a //! structural transaction adds the first stereo stem and its track. //! //! Both gates run through the module's exports on a fresh thread, as the worklet makes the calls, diff --git a/hosts/host-web/tests/empty_boot_fixture.rs b/hosts/host-web/tests/empty_boot_fixture.rs index 5b785a270..1b9bb9e3e 100644 --- a/hosts/host-web/tests/empty_boot_fixture.rs +++ b/hosts/host-web/tests/empty_boot_fixture.rs @@ -1,4 +1,4 @@ -//! TBD30: the empty-boot leg of the browser qualification boots the live-control session with its +//! #1682: the empty-boot leg of the browser qualification boots the live-control session with its //! source, its track and its route taken out, then adds them back by one structural transaction. //! //! The SDK has no encoder for a structural edit, so the page sends the frame the protocol codec From 9d12d492bb4b73ecad17ddd1ac6bb4ac77c0466a Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 15 Oct 2026 15:22:54 +0000 Subject: [PATCH 12/12] Batch 13a review fold: no prose pin on an exclusion, a true epoch comment The #1632 list test asserts that an excluded row carries a reason and a reopen condition, not their wording. The #1616 continuous read's comment names both writers of the epoch and why one load still covers the loop. Co-Authored-By: Claude Opus 5.5 --- crates/controls/tests/list.rs | 3 ++- crates/host-core/src/spectrum.rs | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/controls/tests/list.rs b/crates/controls/tests/list.rs index 9264ccf80..690120ae2 100644 --- a/crates/controls/tests/list.rs +++ b/crates/controls/tests/list.rs @@ -268,7 +268,8 @@ fn follows_mute_and_matrix_mode_are_live_and_not_automatable() { assert!( matches!( d.automation, - controls::AutomationRule::Excluded(exclusion) if exclusion.reopen == "an owner request" + controls::AutomationRule::Excluded(exclusion) + if !exclusion.reason.is_empty() && !exclusion.reopen.is_empty() ), "{}", d.name diff --git a/crates/host-core/src/spectrum.rs b/crates/host-core/src/spectrum.rs index e4d52bb44..81579d24c 100644 --- a/crates/host-core/src/spectrum.rs +++ b/crates/host-core/src/spectrum.rs @@ -919,8 +919,10 @@ impl SpectrumCapture { // entry. A later producer publication belongs to a later read and cannot extend this // budget. self.recovery_pending = false; - // Only control changes the epoch (`commit_continuous`; render's failure path moves it - // forward before it publishes anything of the new epoch), so one load covers the loop. + // Control moves the epoch at a restart (`commit_continuous`) and render's failure path + // moves it forward before it publishes anything of the new epoch. One load covers the + // loop: it returns at the first record of this epoch, and a failure that lands during the + // loop is reported as `Failed` by the next read. let epoch = self.shared.epoch.load(Ordering::Acquire); for _ in 0..available { match self.consumer.try_pop() {