diff --git a/.github/ISSUE_SPECS/457-prepare-paused-pcm-seeks-before-audible-resume.md b/.github/ISSUE_SPECS/457-prepare-paused-pcm-seeks-before-audible-resume.md new file mode 100644 index 000000000..e299caf87 --- /dev/null +++ b/.github/ISSUE_SPECS/457-prepare-paused-pcm-seeks-before-audible-resume.md @@ -0,0 +1,142 @@ +# Prepare paused PCM seeks before audible resume + +## Outcome + +Provide the narrow SDK-owned PCM consumer preparation needed for a browser host to seek while its AudioContext is suspended, refill current-generation PCM, and resume with correct target PCM on the first quantum. The SDK owns ring layout, consumer indices, epoch acknowledgement and worklet processing. The web adapter owns session readiness; applications must not manipulate ring internals. + +## Concrete defect and baseline + +App issue misofm/app#101 integrates SDK445 source 11c52271e1f686d15eeee6fce90107fcdec50b5e and adapter34 source 6d448ea8a2507fcabe062e0e52acb833bdd78588. During paused resume the producer publishes a new generation while old ring slots remain full. App occupancy incorrectly accepts those slots as fresh prefill. After unmuting, the worklet discards them before replacement PCM arrives. Actual eight-stem Ghost evidence records 512 stale slots discarded and 16 underruns between pause and resume. Independent Astra review confirms this is a correctness gap; initial attachment-ready is not post-seek readiness. + +Preserve the original dirty engine checkout. Work from the exact reviewed SDK445 checkpoint in an isolated branch. Do not include unrelated later engine work. + +## Smallest slice and first decision gate + +Before freezing a new public API, use existing actual-Wasm PCM parity fixtures and the existing browser PCM evaluator to prove the proposed bounded, consumer-owned suspended preparation mechanism. Fill both the old internal engine queue and all old shared-ring slots, publish a new seek, prepare on the owning worklet control port while the context stays suspended, refill, then require exact target PCM on the first resumed quantum without an underrun. Confirm the control-port handler actually executes while suspended. + +A source-seek admission ACK is insufficient: Rust PcmSourceProducer.try_seek queues work consumed by render and may return backpressure. The proof must discriminate old internal queue state and engine-applied seek semantics. Stop and report if the message-only mechanism fails; do not silently render/discard a quantum, move the target frame, hide underruns with mute, or weaken the first-quantum assertion. + +If that gate passes, implement one bounded awaited public feed preparation operation using the existing control port and shared seek/stale handling. It releases stale slots only through their owning consumer, retains current-generation work, and reports typed failure/backpressure with explicit close, timeout and supersession behavior. No allocation, lock, I/O, logging or unbounded work is added to process/render. A Rust change, if the decisive proof requires one, needs an amended decision record and review before implementation. + +## Expected boundary + +SDK browser feed, SDK-owned PCM worklet prelude, public types/exports if needed, existing SDK PCM tests/browser evaluator and this spec. Adapter consumption and app archive adoption are bounded successors under app#101. No new storage backend, progressive playback, codec, generic transport framework, benchmark framework, or unrelated architecture work. + +## Acceptance + +- Existing real-Wasm first-quantum discriminator is RED on the old behavior and GREEN on the final behavior with old queues full. +- Suspended-context control delivery and preserved target frame are demonstrated in the existing browser gate. +- Current-generation PCM proof is distinct from ring occupancy and initial attachment; stale or superseded preparation cannot grant readiness. +- Close and timeout reject outstanding preparation; typed queue refusal is preserved, never acknowledged before a drop. +- Proportional existing SDK type/headless/package/browser checks pass; no unnecessary full engine rebuild if Rust/engine assets are unchanged. Modified SDK-owned feed bytes receive accurate package provenance. +- Dedicated Astra medium review records PASS before dependent adapter implementation. Root checkpoints exact paths, pushes and synchronizes this GitHub issue; completion requires upstream evidence. + +## Execution + +User-selected Astra medium implements and a separate Astra medium agent reviews. Root approves this bounded contract; mechanism remains conditional on the first decisive proof. Detailed read-only diagnosis: /private/tmp/dx101-paused-resume-readiness-brief.md. Actual failure evidence: /private/tmp/dx101-ghost-32db3f2.json. No implementation has started. + +## First decision gate: message-only preparation blocked + +Astra medium ran a bounded actual-Wasm probe using the existing SDK sessionDocument fixture and WasmBoundary, with the reviewed SDK445 artifact from the writer checkout. The bytes compare exactly with the app's installed reviewed module; SHA256 `22e4c25cba7f97b66db720ad8ac8cf653de0afcabe84101693f4fa166b90d4e6`. No Rust rebuild or production modification. + +The one-source 48 kHz/128-frame fixture uses a 512-frame internal PCM ring. Four old-generation quanta are accepted; the fifth returns typed backpressure (6), establishing a full internal queue. All 64 shared-ring slots are also filled with old-generation work, then the producer publishes generation 2 at target frame 10000. Actual engine seek admission returns OK, but direct submission of the new target quantum still returns backpressure (6). The first actual render returns zeros; a fresh same-document engine with the same seek and target PCM returns the exact nonzero ramp (left starts 0.00390625, 0.0078125, 0.01171875). Exact first-quantum equality is RED. + +Probe `/private/tmp/dx457-first-quantum-probe.mjs`, output `/private/tmp/dx457-first-quantum-probe.log`; command `node /private/tmp/dx457-first-quantum-probe.mjs` exits 1 at the first-target assertion. This narrower ABI probe does not claim worklet control-port delivery or a complete browser gate. It establishes a prerequisite failure even if stale shared slots were ideally released: the full internal queue cannot accept target PCM until render consumes its pending seek. Consequently no public preparation API or worklet implementation was frozen, no output was silently rendered/discarded, and no counters or target frame were changed. Execution stops at the brief's decision gate. A reviewed amendment is required before any Rust/internal-consumer change or alternative mechanism. + + +## Approved Rust consumer preparation amendment + +Root approves the following bounded amendment after independent Astra medium review. The failed SDK-only prerequisite is preserved above; first-quantum, realtime and ownership gates remain unchanged. Implementation may now start the Rust tranche only, checkpointing its green decisive proof before SDK control-port work. + +# SDK457 amendment proposal: prepare the existing web source seek on its owner + +The retained actual-Wasm RED probe proves the SDK-only mechanism cannot satisfy the frozen first-quantum contract. Amend457 to include the minimal internal consumer preparation below; keep the SDK control-port/readiness work and all original gates. No production implementation has begun. + +## Smallest first implementation tranche + +Strengthen the existing web host's `seek_source` operation so its successful ACK means the admitted seek has also been applied to its exclusively owned source consumer and stale internal transfer blocks have been recycled. Keep the existing web ABI `miso_engine_web_v1_source_seek(handle, id_bytes, generation, frame)` and result codes. No new control-schema record, native transport, source command, or public C API operation is needed. This changes web-host acknowledgement timing, not the target frame or PCM format. Native/C API producers retain their existing queued-seek semantics. + +`AudioWorkletEngineHost` owns `PreparedHost.plan` and invokes it through `&mut self`, on the same worklet owner that executes render. Its source-seek message handler and process callback cannot run simultaneously. Forward a narrow internal `prepare_source_seek(source_index, expected_generation, expected_frame)` operation through the plan's existing executor and graph source-set driver. No alias, shared consumer handle, lock, downcast, or arbitrary control-thread access. The method requires exclusive plan ownership between render blocks; it does not arm native concurrent control access. + +In `PcmSourceConsumer`, reuse the non-consuming prefix of `begin_block` (source/lib.rs1106): finish/recycle any retained played block, flush deferred recycling, apply pending admitted seek command(s), and acquire/recycle stale queued blocks. Stop before the branches that advance next_frame, cumulative_read_frames, underrun counters or played_frames. Retain any matching current-generation block. Bound command work by its prepared queue capacity and data work by transfer_block_count. For an exact requested generation/frame, process older already-admitted seeks through that request; do not acknowledge an older/superseded generation as the requested one. Confirm the resulting active generation and next_frame before returning success. No render_next, DSP processing, output writes, absolute-clock advance or target offset. + +The recycle queue and preallocated transfer blocks already provide ownership return. Do not copy PCM, allocate replacement buffers, overwrite a deferred Box, drop/free a transfer block on the worklet, or introduce an unbounded drain. Explicitly test full data/recycle/current/deferred states against the existing allocation/free observer. Normal begin_block behavior remains unchanged apart from sharing the extracted prefix where appropriate. + +Web seek validation and producer admission remain in SourceControlSet.seek, in their current order. Only after admission succeeds does the owning web host prepare the corresponding canonical source consumer. Source index comes from the host's compiled canonical source order, already used for shape reporting. Prevalidate the internal source capability/index before admission so a missing dispatch cannot yield an admitted producer mutation followed by an unrelated capability refusal. Unexpected generation disagreement must fail honestly; never claim prepared readiness after a partial/mismatched operation. + +## Exact expected paths + +- `crates/source/src/lib.rs`: consumer preparation and SourceGraphSourceSetDriver forwarding; existing source tests including no allocation/free. +- `crates/engine/src/realtime/plan.rs`: narrow executor trait method and exclusive PreparedRenderPlan forwarding. Default unsupported result for plans without streamed sources. +- `crates/graph/src/lib.rs`: source-set driver/GraphExecutor forwarding. No graph topology or schedule changes. +- `hosts/host-web/src/lib.rs`: existing seek_source invokes prepared consumer operation after admission; existing host tests in `hosts/host-web/src/tests.rs`. +- `hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts` / corresponding existing documentation only if its queued-ACK description needs correction; no signature change. Existing ABI/generated asset machinery regenerates affected provenance normally after the required Rust Wasm rebuild. +- Existing SDK457 paths: `sdk/src/browser/pcm-feed.ts`, `sdk/src/browser-assets/miso-engine-v1-pcm-feed-worklet.js`, `sdk/test/browser-pcm-evals.mjs`, public export/type file only if needed, numbered spec. Any exact generated paths follow existing generator output, not manual pin edits. + +No host-core/source producer rewrite is anticipated. If forwarding proves to require another owner or a public ABI addition, report the exact dependency before expanding this list. + +## Decisive proof and checkpoint order + +First tranche is only the Rust consumer/owner forwarding and the existing actual-Wasm RED discriminator translated into the existing SDK PCM evaluator. Fill the internal queue to actual backpressure and all64 shared slots; admit the paused seek, prepare it without rendering, accept target-generation PCM, and require the first render's two planes to equal the fresh same-document/target oracle exactly. Assert status nextAbsoluteSample/renderedQuanta and source cumulative-read/underrun state did not advance during preparation. Include zero/current/stale block and bounded queued-seek coverage with allocation/free proof. Run proportional Rust tests and required updated Wasm artifact checks; pause exact-path coherent checkpoint as soon as this first-target proof is GREEN. + +Then the SDK control operation can safely apply the same existing source_seek on its owning worklet port, release old SAB slots, acknowledge exact epoch/generation, and permit fresh refill. Preserve close/timeout/supersession/backpressure as in457. Prove actual suspended-context port delivery and exact first target PCM in the existing browser gate. Only after reviewed SDK PASS does the separate adapter successor await this stronger preparation plus fresh prefill before paused seek readiness; app consumes that public promise. + +This proposal requires dedicated review and a synchronized457 amendment before implementation. It preserves the stop condition: if first-target proof still fails, report the new concrete cause, never add an output-discard/mute/silence workaround. + +## Rust preparation tranche evidence + +Implemented after reviewed amendment20ceb425. Existing web seek now prevalidates source/consumer dispatch, admits the producer seek, and applies it through the exclusive plan/executor/graph driver. Consumer preparation reuses bounded seek observation and stale acquisition/recycling, without consuming target PCM or advancing read/underrun/time state. Native producer command admission is unchanged. A second web seek can now succeed before render because the previous acknowledged command was consumed; the existing web unit assertion was translated accordingly, while native source queue backpressure tests remain intact. + +Root authorized the exact existing `hosts/host-web/tests/boot_transient_budget.rs` path for allocation/free proof using its allocator, with two operation counters added to that observer. Full old internal queues, an intervening render/refill, and repeated seeks report zero allocations, zero frees and unchanged clock/quanta. Source tests also cover retained stale/current blocks, mismatched generation refusal and exact next target output. No new allocator framework. + +Rust library tests for engine/graph/host-web/source PASS:194 passed, one existing ignored; focused allocation test PASS; existing SDK PCM evaluator9/9 PASS. Formatting and diff check PASS. Logs `/private/tmp/dx457-rust-{focused,tests}.log`, `/private/tmp/dx457-allocation.log`, `/private/tmp/dx457-pcm-focused.log`. + +The same added actual-Wasm assertion in `sdk/test/browser-pcm-evals.mjs` is RED against reviewed SDK445 (`/private/tmp/dx457-wasm-first-red.log`: target admission backpressure, first planes zero) and GREEN against this tranche (`/private/tmp/dx457-wasm-first-green.log`: exact first left/right target planes). Both internal and shared old queues are full before seek; the test explicitly distinguishes direct Rust admission proof from the pending SDK shared-ring/control-port mechanism. No hidden render or target-frame offset. + +For this decisive proof only, root authorized the existing build script's exact Rust flags with persistent target `/private/tmp/dx457-wasm-target`: `CARGO_TARGET_DIR=/private/tmp/dx457-wasm-target RUSTFLAGS="-C target-feature=+simd128 -C strip=debuginfo --remap-path-prefix=${CARGO_HOME:-$HOME/.cargo}=/cargo --remap-path-prefix=/private/tmp/miso-dx-sdk-resume=/repo" cargo build --locked --release --target wasm32-unknown-unknown -p host-web`. Build PASS, log `/private/tmp/dx457-wasm-build.log`. Provisional artifact `/private/tmp/dx457-probe-artifacts/miso-engine-v1-audio-worklet.simd128.wasm`, SHA256 `fa0039d8119ce34efd2c1a5b6540252b4a27a36bb1fe1535a5efbd838506ed4c`. Existing release pin/generated package assets were not manually changed. Final canonical artifact build/promotion, SDK control-port implementation, real suspended-browser proof and package qualification remain pending. Pause this coherent Rust tranche for root's exact-path checkpoint before further implementation. + +## SDK consumer control-port tranche after cad8b6db + +Public `EngineFeed.prepareSeek({ timeoutMs? })` is separate from initial attachment-ready and from producer prefill. It requires an attached feed and suspended context, permits one outstanding request, and sends captured full generation/frame/epoch identities over the existing attach port. Worklet preparation uses the strengthened web seek, releases only provably older shared slots and retains current/future PCM. The ACK must match both the request and live identity, with the context still suspended. Concurrent calls reject `prepareBusy`; supersession, wrong state, actual engine refusal (including numeric result6), close and timeout remain distinct typed failures. Timeout closes the feed. No new ring layout or rendering operation. + +Independent review caught two races during this implementation pass: a superseded request must not discard future-generation slots, and control preparation must apply its captured tuple rather than mix a captured epoch with freshly read generation/frame words. Both are corrected and discriminated in the existing PCM evaluator. Steady render retains lazy generation/frame reads only on a changed epoch; typed-array allocation mutation remains RED in the existing gate. No new framework. + +Publication precondition is explicit in public documentation: await the producer seek ACK, then serialize the synchronous prepareSeek snapshot/post against any other producer seek commands. Shared generation/frame words precede the epoch publication, so an already in-progress producer publication is not a valid input snapshot. The existing adapter Worker ACK/lifecycle queue supplies this ordering. Later seeks may supersede a pending request and are handled by the captured-identity checks above; this does not introduce another transport or queue. + +Existing PCM evaluator12/12 PASS, including actual-Wasm full internal/SAB queues through the real prelude/control handler, exact first target planes, unchanged preparation sample/drain/underrun state, fresh-slot retention, both supersession races, bounded concurrent admission, typed refusal, close and timeout. SDK types, syntax and diff check PASS. Logs `/private/tmp/dx457-port-{pcm,types,build,stage}.log`. + +The existing `sdk/test/package-tarball-smoke.mjs` browser gate now includes one paused-seek case, retaining both original boot/factory cases. Its test-only first-quantum recorder observes actual engine output; it is not a production processor or SDK dependency. Using a provisional package with the honestly identified local Wasm above, Vite/Chromium PASS: preparation completes while context.state remains suspended, AudioContext time and engine sample clock unchanged, occupancy0 after64 stale slots; the first resumed two planes exactly equal a fresh same-document/target Wasm oracle. After capture: underruns0, refused0, torn0, errors0, seeksApplied1, submittedGenerationTag2. Evidence `/private/tmp/dx457-port-browser.json` and `/private/tmp/dx457-port-browser-corrected.log`; the tested packed feed bytes compare exactly with current source. + +The first browser invocation hit sandbox localhost EPERM before launch; authorized escalation followed. The first actual browser invocation exposed missing required frames/sampleRateHz in the new test's public host submission, corrected in that same existing test before the passing run; no production workaround. An initial npm pack cache permission failure used a task-owned cache on retry. Canonical Linux artifact promotion, final package qualification and final dedicated457 verdict remain pending; this provisional browser result is not a release-package PASS. Pause these five exact paths for root checkpoint before pin promotion or further implementation. + +## Canonical Linux artifact identity and source review + +Dedicated Astra medium review passes the Rust tranche at cad8b6db and the SDK control-port tranche at 30aa7009. Reports /private/tmp/dx-457-astra-rust-tranche-review.md and /private/tmp/dx-457-astra-port-tranche-review.md include independent first-target and supersession checks. This is source acceptance, not final package or adapter acceptance. + +Existing qualification run 33959847637 on exact cad8b6db8370fa67da7e8549bfb4fec4e738921f used Ubuntu 24.04.4 (image 20260831.293.1), Rust 1.97.1 x86_64-unknown-linux-gnu. Its artifact job 101289741547 observed WASM SHA256 271a2bf3c8cf52f5156dadec091efd399324cd3f0c51aa7b4a2a08e632a648ce and correctly refused the old 22e4c25 pin, uploading nothing. All independent native/debug/release/audit/cross-target/wasmtime/lint/docs jobs passed; SDK/browser/artifact consumers were skipped after that expected failure. The overall run is FAIL, not qualification PASS. Raw evidence /private/tmp/dx457-linux-33959847637-artifact.log and identity record /private/tmp/dx457-linux-33959847637-identity.md. + +Update only the authoritative source pin to that actually observed Linux digest. The Rust source is unchanged by 30aa7009; no provisional Darwin artifact is relabeled, no generated payload or old package provenance is patched. The normal existing qualification workflow must now re-earn the pin and upload its six-file closure before canonical SDK packaging. Final package/browser qualification and reviewed archive remain pending. + +## Canonical qualification and bounded deletion-scan correction + +The existing Ubuntu artifact job101291838905 in run33960625717 on facce76e4ed4218e5581e7ecbf736879b7470d18 passed and uploaded artifact9967841154. Its six-file closure is retained at `/private/tmp/dx457-canonical-artifacts`, with provenance `/private/tmp/dx457-canonical-artifacts-provenance.json`. The 2637943-byte Wasm has the pinned SHA256271a2bf3c8cf52f5156dadec091efd399324cd3f0c51aa7b4a2a08e632a648ce. Local existing headless check with that explicit directory passed167 tests with one existing skip; package check with the same directory and existing Chromium tools passed, including actual suspended-context preparation and exact first resumed target planes. Logs `/private/tmp/dx457-final-headless.log` and `/private/tmp/dx457-final-package-browser.log`. + +CI separately exposed deletion-scan spelling collisions. The internal preparation reply kind is now `confirmed` in the feed, prelude and existing PCM tests; public prepareSeek behavior, message operation and every assertion remain unchanged. The same scan then exposed the pre-existing scratch test's local `phase` variable over handshake/request, incorrectly matching its error-phase pattern. Rename only that loop variable to `scratchStage` in the existing `sdk/test/browser-defaults-evals.mjs`; retain all cases and names. This exact additional test path is a proportional CI correction, not a boot contract change. The checker is unchanged. Existing deletion scan now PASS, PCM plus defaults31/31 PASS, TypeScript and diff check PASS: `/private/tmp/dx457-final-deletions-green.log`, `/private/tmp/dx457-final-port-defaults.log`, `/private/tmp/dx457-final-types.log`. + +These local package/browser results precede the internal reply spelling correction; the final corrected package must be checked before release. The same CI run's browser jobs refused the old recorded Wasm lineage before executing browser assertions. That historical matrix cannot be relabeled: its refresh requires the existing actual Linux qualification recording workflow. Final CI, corrected package qualification and reviewed archive remain pending. Pause this coherent five-path correction for the root checkpoint. + +## Reviewed functional archive and actual Linux browser records + +The corrected canonical SDK package at source175755e9cb94c4eebba164e0bf68c3b3d89582b1 passes package and real suspended-browser checks. Archive /private/tmp/dx-reviewed-sdk457/misofm-engine-0.1.0.tgz is 950781 bytes, SHA2560df6ce51f2771c246f0b00932199bcc20c85a2d10e371e99f247eff9d206c906; adjacent provenance/payload-manifest/browser-evidence files record SHA512, all77 files and the six canonical Linux assets. Independent Astra functional-package review PASS (/private/tmp/dx-457-astra-functional-package-review.md) authorizes adapter consumption while final qualification completes. Later evidence-only record promotion does not relabel that immutable archive's source. + +The normal run33960625717 correctly rejected the stale checked browser lineage before browser execution. Root authorized one temporary qualification branch to run the unchanged official all-browser recording command on Ubuntu24.04/Node22 with pinned Playwright, preserving source-pin checks and mutation proofs. The first recording run33961500605 failed before browser work because the temporary single job omitted a structural anchor required by the existing routing mutation suite. One bounded four-line correction retained a single-entry matrix; both unchanged local routing suites passed before the corrected dispatch. No validator or product workflow was weakened. + +Corrected run33961832434 completed SUCCESS, including SDK package and all other jobs. Browser job101295176132 executed the unchanged six gates and mutation proofs on Chromium151.0.7922.34, Firefox153.0 and WebKit26.5 against canonical WASM271a2bf3c8cf52f5156dadec091efd399324cd3f0c51aa7b4a2a08e632a648ce. Actual executed candidate0d7102bfea894d746ec9d779f197918b1ed0bb54 differs from reviewed SDK175755 only in temporary qualification workflow wiring. The generated records retain that real candidate identity; no future source identity is substituted. + +Promote only the two actual generated files from artifact9968219042: hosts/host-web/qualification/results.json and hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md. Dedicated Astra review PASS (/private/tmp/dx-457-astra-linux-record-review.md) verified raw logs, all rows, exact rendered Markdown, canonical hash and source provenance. Receipt /private/tmp/dx457-linux-matrix-33961832434-summary.md and adjacent provenance preserve the operation. The temporary workflow is not promoted. Normal production --check-matrix qualification on this record checkpoint remains the final SDK457 acceptance gate; adapter36 implementation proceeds from the separately reviewed functional archive. + +## Final ordinary qualification and dedicated review — PASS + +The unchanged production qualification workflow completed SUCCESS in [run33962335128](https://github.com/misofm/engine/actions/runs/33962335128) at exact primary checkpoint777fb0ef8e452149fbac1fca611d572f4a711634. Every job passed: canonical artifact build/pin and artifact gates, SDK package/generated surface, all three ordinary browser check-matrix jobs with existing mutation proofs, native/debug/release/audit/cross-target/wasmtime, lint/docs and aggregate. The actual Linux recording candidate0d7102bfea894d746ec9d779f197918b1ed0bb54 remains unchanged; no temporary workflow was promoted. Final receipt `/private/tmp/dx457-final-33962335128-summary.md` and adjacent run JSON preserve the exact results. + +Dedicated independent Astra medium final review PASS at777fb0ef (`/private/tmp/dx-457-astra-medium-review.md`) confirms the bounded SDK contract, actual suspended first-target PCM proof, immutable package identity and normal CI closure gate. The functional archive remains source175755e9cb94c4eebba164e0bf68c3b3d89582b1, SHA2560df6ce51f2771c246f0b00932199bcc20c85a2d10e371e99f247eff9d206c906, at `/private/tmp/dx-reviewed-sdk457/misofm-engine-0.1.0.tgz`; later evidence-only commits do not relabel its source. Root owns the final evidence checkpoint, GitHub synchronization and issue closure. Adapter adoption and app playback acceptance remain separate; this SDK browser-host qualification does not claim app WebKit OPFS playback. diff --git a/crates/engine/src/realtime/plan.rs b/crates/engine/src/realtime/plan.rs index ff240fc60..5639e0526 100644 --- a/crates/engine/src/realtime/plan.rs +++ b/crates/engine/src/realtime/plan.rs @@ -172,6 +172,14 @@ impl PlanUnitEligibility { /// implementations are policy-limited to `graph`. #[doc(hidden)] pub trait PreparedPlanExecutor: Send { + /// Whether the exclusively owned source consumer supports between-block seek preparation. + fn can_prepare_source_seek(&self, _source_index: usize) -> bool { + false + } + /// Apply an admitted source seek without rendering or advancing any sample clock. + fn prepare_source_seek(&mut self, _source_index: usize, _generation: u64, _frame: u64) -> bool { + false + } /// Render one already-validated block using only preallocated state. fn render( &mut self, @@ -535,6 +543,27 @@ impl PreparedRenderPlan { .as_deref() .map_or([0; 4], PreparedPlanExecutor::dispatch_counters) } + /// Prevalidate source preparation before a host admits the producer-side seek. + /// Requires the plan owner; it provides no concurrent control-side consumer handle. + pub fn can_prepare_source_seek(&self, source_index: usize) -> bool { + self.executor + .as_ref() + .is_some_and(|executor| executor.can_prepare_source_seek(source_index)) + } + + /// Prepare an admitted seek on the exclusive render owner between blocks. + /// Does not render, mutate topology, or advance the plan's sample clock. + pub fn prepare_source_seek( + &mut self, + source_index: usize, + generation: u64, + frame: u64, + ) -> bool { + self.executor + .as_mut() + .is_some_and(|executor| executor.prepare_source_seek(source_index, generation, frame)) + } + /// The plan's internal executor, for the block-boundary hand-over in `plan_exchange`. pub(crate) fn executor_mut(&mut self) -> Option<&mut (dyn PreparedPlanExecutor + 'static)> { self.executor.as_deref_mut() diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 0f852f955..fd62fa265 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1106,6 +1106,12 @@ impl GraphSourceSetResourceReport { /// Implementors own prepared source consumers and source-plane storage. The graph invokes this /// only on its coordinator before ordinary nodes or native dependency waves begin. pub trait GraphPreparedSourceSetDriver: Send { + fn can_prepare_source_seek(&self, _source_index: usize) -> bool { + false + } + fn prepare_source_seek(&mut self, _source_index: usize, _generation: u64, _frame: u64) -> bool { + false + } fn claim_count(&self) -> usize; fn begin_block(&mut self, first_sample: u64, frames: u32) -> Result<(), RenderError>; fn copy_track_input( @@ -1393,6 +1399,19 @@ impl GraphExecutor { } impl PreparedPlanExecutor for GraphExecutor { + fn can_prepare_source_seek(&self, source_index: usize) -> bool { + self.source_set + .as_ref() + .is_some_and(|set| set.driver.can_prepare_source_seek(source_index)) + } + + fn prepare_source_seek(&mut self, source_index: usize, generation: u64, frame: u64) -> bool { + self.source_set.as_mut().is_some_and(|set| { + set.driver + .prepare_source_seek(source_index, generation, frame) + }) + } + // REALTIME_POLICY_BEGIN fn render( &mut self, diff --git a/crates/source/src/lib.rs b/crates/source/src/lib.rs index 533fc5022..9c82af28a 100644 --- a/crates/source/src/lib.rs +++ b/crates/source/src/lib.rs @@ -1041,6 +1041,22 @@ pub struct PcmSourceConsumer { } impl PcmSourceConsumer { + /// Apply an already-admitted seek on the exclusive consumer owner between blocks. + /// Recycles stale storage and retains current-generation PCM without consuming a frame. + /// Native producers still only enqueue commands; this is not a shared controller handle. + pub fn prepare_seek(&mut self, generation: SourceGeneration, frame: SourceFrame) -> bool { + self.end_block(); + self.flush_deferred_recycle(); + // The prepared source command queue has one slot. Observe exactly that admitted + // command, then check the requested identity before granting readiness. + self.observe_seek_at_block_boundary(); + if self.active_generation != generation || self.next_frame != frame { + return false; + } + self.acquire_current_block(); + true + } + /// Immutable prepared ring shape shared with the producer endpoint. #[must_use] pub const fn shape(&self) -> PcmSourceShape { @@ -1532,6 +1548,19 @@ fn source_set_retained_resources( } impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { + fn can_prepare_source_seek(&self, source_index: usize) -> bool { + source_index < self.sources.len() + } + + fn prepare_source_seek(&mut self, source_index: usize, generation: u64, frame: u64) -> bool { + let Some(generation) = SourceGeneration::new(generation) else { + return false; + }; + self.sources + .get_mut(source_index) + .is_some_and(|source| source.consumer.prepare_seek(generation, SourceFrame(frame))) + } + fn claim_count(&self) -> usize { self.mappings.len() } @@ -1784,6 +1813,41 @@ mod tests { assert!(report.largest_allocation_bytes >= 32); } + #[test] + fn paused_seek_prepares_full_queues_without_consuming_target() { + for retained in [false, true] { + let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).unwrap(); + let mut host = producer.into_host_chunk_provider(RATE); + let old = [0.25; 4]; + host.submit(chunk(1, 0, &[&old], 4, false)).unwrap(); + host.submit(chunk(1, 4, &[&old], 4, false)).unwrap(); + if retained { + consumer.acquire_current_block(); + } + host.try_seek(SourceCommand::Seek { + generation: SourceGeneration(2), + frame: SourceFrame(100), + }) + .unwrap(); + assert!(consumer.prepare_seek(SourceGeneration(2), SourceFrame(100))); + assert_eq!(consumer.next_frame, SourceFrame(100)); + assert_eq!(consumer.cumulative_read_frames, 0); + assert_eq!(consumer.underrun_frames, 0); + assert_eq!(consumer.underrun_events, 0); + assert_eq!(consumer.stale_generation_discard_count, 2); + let target = [1.0, 2.0, 3.0, 4.0]; + host.submit(chunk(2, 100, &[&target], 4, false)).unwrap(); + // Preparing again retains current-generation PCM and never consumes it. + assert!(consumer.prepare_seek(SourceGeneration(2), SourceFrame(100))); + assert!(!consumer.prepare_seek(SourceGeneration(1), SourceFrame(100))); + let mut output = [0.0; 4]; + let report = consumer.read_block(&mut [&mut output]).unwrap(); + assert_eq!(output, target); + assert_eq!(report.underrun_frames, 0); + assert_eq!(consumer.next_frame, SourceFrame(104)); + } + } + #[test] fn prepare_rejects_invalid_fixed_ring_shape() { assert!(matches!( diff --git a/hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md b/hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md index 9bd12d1c4..b17950a31 100644 --- a/hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md +++ b/hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md @@ -1,7 +1,7 @@ # Browser deployment matrix -This matrix is generated from the pinned Playwright 1.62.1 headless Linux qualification run over candidate `fc01c534cce3d8c1464e489955bdab8bb45d9fe1` and the single shipped simd128 AudioWorklet artifact `22e4c25cba7f97b66db720ad8ac8cf653de0afcabe84101693f4fa166b90d4e6`. The version shown is the lowest version qualified by this run; older versions are unqualified, not implicitly supported. +This matrix is generated from the pinned Playwright 1.62.1 headless Linux qualification run over candidate `0d7102bfea894d746ec9d779f197918b1ed0bb54` and the single shipped simd128 AudioWorklet artifact `271a2bf3c8cf52f5156dadec091efd399324cd3f0c51aa7b4a2a08e632a648ce`. The version shown is the lowest version qualified by this run; older versions are unqualified, not implicitly supported. | Browser engine | Qualified version floor | Attestation outcome | SIMD gate | AudioWorklet boot | Native corpus digest | Live console (#137) | Observation (#143) | 100 ms main-thread stall | | --- | --- | --- | --- | --- | --- | --- | --- | --- | diff --git a/hosts/host-web/qualification/results.json b/hosts/host-web/qualification/results.json index d83e77303..74dc53db6 100644 --- a/hosts/host-web/qualification/results.json +++ b/hosts/host-web/qualification/results.json @@ -1,7 +1,7 @@ { "schema": "miso.web.qualification.matrix.v1", - "candidateCommit": "fc01c534cce3d8c1464e489955bdab8bb45d9fe1", - "wasmSha256": "22e4c25cba7f97b66db720ad8ac8cf653de0afcabe84101693f4fa166b90d4e6", + "candidateCommit": "0d7102bfea894d746ec9d779f197918b1ed0bb54", + "wasmSha256": "271a2bf3c8cf52f5156dadec091efd399324cd3f0c51aa7b4a2a08e632a648ce", "playwrightVersion": "1.62.1", "platform": "linux-headless", "artifact": "single shipped simd128 AudioWorklet artifact", diff --git a/hosts/host-web/src/lib.rs b/hosts/host-web/src/lib.rs index a5500130d..2604dfa6f 100644 --- a/hosts/host-web/src/lib.rs +++ b/hosts/host-web/src/lib.rs @@ -1184,7 +1184,9 @@ impl AudioWorkletEngineHost { self.record(code) } - /// Queue one strictly increasing generation-tagged absolute source seek. + /// Apply one strictly increasing generation-tagged source seek between render blocks. + /// This web host owns both producer and render plan on one exclusive thread. Successful + /// admission also prepares its consumer so new PCM can enter even when old queues were full. pub fn seek_source(&mut self, source_id: &[u8], generation: u64, source_frame: u64) -> u32 { if self.status.state != STATE_READY { return self.record(RESULT_WRONG_STATE); @@ -1192,8 +1194,30 @@ impl AudioWorkletEngineHost { let Some(ready) = self.ready.as_mut() else { return self.fail(RESULT_INTERNAL, b"web.internal.ready\t$\n"); }; + let Some(source_index) = ready + .session + .normalized_model() + .sources + .iter() + .position(|source| source.id.as_str().as_bytes() == source_id) + else { + return self.record(RESULT_INVALID_ARGUMENT); + }; + if !ready.host.plan.can_prepare_source_seek(source_index) { + return self.record(RESULT_WRONG_STATE); + } let code = match ready.host.sources.seek(source_id, generation, source_frame) { - Ok(()) => RESULT_OK, + Ok(()) => { + if ready + .host + .plan + .prepare_source_seek(source_index, generation, source_frame) + { + RESULT_OK + } else { + RESULT_INTERNAL + } + } Err(error) => source_result(error), }; self.record(code) diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 3896cc0e5..56b746991 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -830,12 +830,73 @@ fn source_backpressure_seek_render_and_stable_output_are_bounded() { RESULT_OK ); assert_eq!(host.seek_source(b"fixture-source", 2, 0), RESULT_OK); + assert_eq!(host.seek_source(b"fixture-source", 3, 0), RESULT_OK); + assert_eq!(host.render_next(), RESULT_OK); + assert_eq!(host.status().rendered_quanta, 2); +} + +#[test] +fn paused_seek_recycles_full_internal_queue_before_first_target_quantum() { + let quantum = 128; + let document = identity_session(quantum, 512, 480_000); + let options = WebBootOptions { + source_ring_frames: 512, + ..boot_options(quantum) + }; + let mut host = AudioWorkletEngineHost::boot(document.as_bytes(), options).unwrap(); + let old = [0.25; 128]; + for block in 0..4 { + assert_eq!( + host.submit_source( + b"fixture-source", + 1, + block * 128, + 48_000, + &[&old, &old], + quantum, + false + ), + RESULT_OK + ); + } assert_eq!( - host.seek_source(b"fixture-source", 3, 0), + host.submit_source( + b"fixture-source", + 1, + 512, + 48_000, + &[&old, &old], + quantum, + false + ), RESULT_BACKPRESSURE ); + assert_eq!( + host.seek_source(b"unknown", 2, 10_000), + RESULT_INVALID_ARGUMENT + ); + assert_eq!(host.seek_source(b"fixture-source", 2, 10_000), RESULT_OK); + assert_eq!(host.status().next_absolute_sample, 0); + assert_eq!(host.status().rendered_quanta, 0); + let left = core::array::from_fn::<_, 128, _>(|index| (index + 1) as f32 / 256.0); + let right = core::array::from_fn::<_, 128, _>(|index| -(index as f32 + 1.0) / 512.0); + assert_eq!( + host.submit_source( + b"fixture-source", + 2, + 10_000, + 48_000, + &[&left, &right], + quantum, + false + ), + RESULT_OK + ); assert_eq!(host.render_next(), RESULT_OK); - assert_eq!(host.status().rendered_quanta, 2); + let output = host.output_pcm().unwrap(); + assert_eq!(&output[..128], &left); + assert_eq!(&output[128..256], &right); + assert_eq!(host.status().next_absolute_sample, 128); } #[test] diff --git a/hosts/host-web/tests/boot_transient_budget.rs b/hosts/host-web/tests/boot_transient_budget.rs index 5bd51c803..e487720c9 100644 --- a/hosts/host-web/tests/boot_transient_budget.rs +++ b/hosts/host-web/tests/boot_transient_budget.rs @@ -19,6 +19,8 @@ thread_local! { static ARMED: Cell = const { Cell::new(false) }; static LIVE: Cell = const { Cell::new(0) }; static PEAK: Cell = const { Cell::new(0) }; + static ALLOCATIONS: Cell = const { Cell::new(0) }; + static DEALLOCATIONS: Cell = const { Cell::new(0) }; } struct PeakAllocator; @@ -30,6 +32,7 @@ fn allocated(bytes: usize) { if !ARMED.try_with(Cell::get).unwrap_or(false) { return; } + ALLOCATIONS.with(|count| count.set(count.get() + 1)); LIVE.with(|live| { let next = live.get().saturating_add(bytes); live.set(next); @@ -41,6 +44,7 @@ fn deallocated(bytes: usize) { if !ARMED.try_with(Cell::get).unwrap_or(false) { return; } + DEALLOCATIONS.with(|count| count.set(count.get() + 1)); LIVE.with(|live| live.set(live.get().saturating_sub(bytes))); } @@ -89,6 +93,8 @@ fn measured_peak(operation: impl FnOnce() -> T) -> (T, usize) { ARMED.with(|armed| armed.set(false)); LIVE.with(|live| live.set(0)); PEAK.with(|peak| peak.set(0)); + ALLOCATIONS.with(|count| count.set(0)); + DEALLOCATIONS.with(|count| count.set(0)); ARMED.with(|armed| armed.set(true)); let result = operation(); ARMED.with(|armed| armed.set(false)); @@ -96,6 +102,77 @@ fn measured_peak(operation: impl FnOnce() -> T) -> (T, usize) { (result, peak) } +#[test] +fn paused_seek_recycles_without_allocating_or_freeing() { + use host_web::{RESULT_BACKPRESSURE, RESULT_OK}; + let mut model = parse_session_json(include_str!("browser-v1/session.json")).unwrap(); + model.sources[0].frames = 480_000; + let document = canonical_session_json(&model).unwrap(); + let options = WebBootOptions { + source_ring_frames: 512, + ..WebBootOptions::explicit_defaults() + }; + let mut host = AudioWorkletEngineHost::boot(document.as_bytes(), options).unwrap(); + let samples = [0.25; 128]; + let planes: [&[f32]; 2] = [&samples, &samples]; + for generation in 1..=3 { + let origin = (generation - 1) * 10_000; + for block in 0..4 { + assert_eq!( + host.submit_source( + b"fixture-source", + generation, + origin + block * 128, + 48_000, + &planes, + 128, + false + ), + RESULT_OK + ); + } + assert_eq!( + host.submit_source( + b"fixture-source", + generation, + origin + 512, + 48_000, + &planes, + 128, + false + ), + RESULT_BACKPRESSURE + ); + if generation == 2 { + // Exercise recycled/retained storage after a real render, then refill it. + assert_eq!(host.render_next(), RESULT_OK); + assert_eq!( + host.submit_source( + b"fixture-source", + generation, + origin + 512, + 48_000, + &planes, + 128, + false + ), + RESULT_OK + ); + } + let clock = host.status().next_absolute_sample; + let rendered = host.status().rendered_quanta; + let (result, peak) = measured_peak(|| { + host.seek_source(b"fixture-source", generation + 1, generation * 10_000) + }); + assert_eq!(result, RESULT_OK); + assert_eq!(peak, 0); + assert_eq!(ALLOCATIONS.with(Cell::get), 0); + assert_eq!(DEALLOCATIONS.with(Cell::get), 0); + assert_eq!(host.status().next_absolute_sample, clock); + assert_eq!(host.status().rendered_quanta, rendered); + } +} + fn unlimited_caps() -> CompileCaps { CompileCaps { max_compiled_model_bytes: u64::MAX, diff --git a/hosts/host-web/web/miso-engine-v1-audio-worklet-artifact.sha256 b/hosts/host-web/web/miso-engine-v1-audio-worklet-artifact.sha256 index 0f4874c55..a04b69e96 100644 --- a/hosts/host-web/web/miso-engine-v1-audio-worklet-artifact.sha256 +++ b/hosts/host-web/web/miso-engine-v1-audio-worklet-artifact.sha256 @@ -1 +1 @@ -22e4c25cba7f97b66db720ad8ac8cf653de0afcabe84101693f4fa166b90d4e6 \ No newline at end of file +271a2bf3c8cf52f5156dadec091efd399324cd3f0c51aa7b4a2a08e632a648ce \ No newline at end of file diff --git a/sdk/src/browser-assets/miso-engine-v1-pcm-feed-worklet.js b/sdk/src/browser-assets/miso-engine-v1-pcm-feed-worklet.js index 0bce102f1..13086300e 100644 --- a/sdk/src/browser-assets/miso-engine-v1-pcm-feed-worklet.js +++ b/sdk/src/browser-assets/miso-engine-v1-pcm-feed-worklet.js @@ -127,6 +127,7 @@ const FLAG_END_OF_REGION = 1 const RESULT_OK = 0 const RESULT_BACKPRESSURE = 6 +const RESULT_WRONG_STATE = 3 /** The one engine processor in this scope, and any rings that arrived before * it existed. Both nodes are constructed by the same main thread in a fixed @@ -337,32 +338,7 @@ function wrapEngineProcessor(Base) { if (ring.idTargetBuffer !== this.memoryBuffer) return if (ring.idTarget.length !== ring.idLength) return - const epoch = Atomics.load(control, CONTROL_SEEK_EPOCH) - if (epoch !== ring.seenEpoch) { - ring.idTarget.set(ring.idBytes) - const result = this.exports.miso_engine_web_v1_source_seek( - this.handle, - ring.idLength, - ring.controlI64[CONTROL_I64_SEEK_GENERATION], - ring.controlI64[CONTROL_I64_SEEK_FRAME] - ) - if (result === RESULT_BACKPRESSURE) { - // Ordinary flow control. Leave the epoch unseen and retry the same - // seek before touching any slots on the next process call. - return - } - if (result !== RESULT_OK) { - control[CONTROL_REFUSED] += 1 - control[CONTROL_LAST_RESULT] = result - return - } - ring.seenEpoch = epoch - control[CONTROL_SEEKS_APPLIED] += 1 - // The engine dropped everything it held for this source. - ring.depth = 0 - ring.finished = false - control[CONTROL_FINISHED] = 0 - } + if (this.applySharedSeek(ring) !== RESULT_OK) return const generationTag = Atomics.load(control, CONTROL_GENERATION_TAG) const write = Atomics.load(control, CONTROL_WRITE_INDEX) @@ -455,6 +431,85 @@ function wrapEngineProcessor(Base) { } control[CONTROL_DEPTH] = ring.depth } + + /** Shared by render and the between-block control handler; never consumes PCM. */ + applySharedSeek(ring, epoch = Atomics.load(ring.control, CONTROL_SEEK_EPOCH), generation, frame) { + const control = ring.control + if (epoch !== ring.seenEpoch) { + ring.idTarget.set(ring.idBytes) + const result = this.exports.miso_engine_web_v1_source_seek( + this.handle, + ring.idLength, + generation ?? ring.controlI64[CONTROL_I64_SEEK_GENERATION], + frame ?? ring.controlI64[CONTROL_I64_SEEK_FRAME] + ) + if (result === RESULT_BACKPRESSURE) { + // Ordinary flow control. Leave the epoch unseen and retry the same + // seek before touching any slots on the next process call. + return result + } + if (result !== RESULT_OK) { + control[CONTROL_REFUSED] += 1 + control[CONTROL_LAST_RESULT] = result + return result + } + ring.seenEpoch = epoch + control[CONTROL_SEEKS_APPLIED] += 1 + // The engine dropped everything it held for this source. + ring.depth = 0 + ring.finished = false + control[CONTROL_FINISHED] = 0 + control[CONTROL_DEPTH] = 0 + } + return RESULT_OK + } + + /** Consumer-owned stale release, called only by the attachment port between blocks. */ + prepareSharedSeeks(delivered, seeks) { + if (!this.ready || this.disposed || this.stickyResult !== RESULT_OK || this.exports.memory.buffer !== this.memoryBuffer || !Array.isArray(seeks) || seeks.length !== delivered.length) return { kind: "refused", result: RESULT_WRONG_STATE } + const rings = delivered.map((shared) => this.sabRings.find((ring) => ring.shared === shared)) + if (rings.some((ring) => !ring || ring.idTargetBuffer !== this.memoryBuffer || ring.idTarget.length !== ring.idLength)) return { kind: "refused", result: RESULT_WRONG_STATE } + const matches = (ring, seek) => seek && seek.epoch !== 0 && Atomics.load(ring.control, CONTROL_SEEK_EPOCH) === seek.epoch && Atomics.load(ring.controlI64, CONTROL_I64_SEEK_GENERATION) === seek.generation && Atomics.load(ring.controlI64, CONTROL_I64_SEEK_FRAME) === seek.frame + if (!rings.every((ring, index) => matches(ring, seeks[index]))) return { kind: "superseded" } + for (let index = 0; index < rings.length; index += 1) { + const ring = rings[index] + if (!matches(ring, seeks[index])) return { kind: "superseded" } + const result = this.applySharedSeek(ring, seeks[index].epoch, seeks[index].generation, seeks[index].frame) + if (result !== RESULT_OK) return { kind: "refused", result } + if (!this.releaseStaleSharedSlots(ring, seeks[index].generation)) return { kind: "refused", result: 1 } + } + return rings.every((ring, index) => matches(ring, seeks[index])) ? { kind: "confirmed", seeks } : { kind: "superseded" } + } + + releaseStaleSharedSlots(ring, generation) { + const control = ring.control + const write = Atomics.load(control, CONTROL_WRITE_INDEX) + const capacityMask = ring.capacity - 1 + let read = control[CONTROL_READ_INDEX] + for (let remaining = ring.capacity; read !== write && remaining > 0; remaining -= 1) { + const slot = read & capacityMask + const word = slot * (SLOT_HEADER_BYTES / 4) + if (ring.headers[word + SLOT_SEQUENCE] !== read) { + // The slot the index points at is not the chunk the index names. + // Only a second writer can do that; stop rather than submit it. + control[CONTROL_TORN] += 1 + Atomics.store(control, CONTROL_READ_INDEX, read) + return false + } + const word64 = slot * (SLOT_HEADER_BYTES / 8) + // A producer may supersede this request while its control message runs. + // Future-generation PCM belongs to that seek and must never be discarded. + if (BigInt.asUintN(64, ring.headersI64[word64 + SLOT_I64_GENERATION]) < BigInt.asUintN(64, generation)) { + read = (read + 1) & SAB_WRAP_MASK + control[CONTROL_STALE] += 1 + continue + } + // Retain current-generation work without submitting or consuming it. + break + } + Atomics.store(control, CONTROL_READ_INDEX, read) + return true + } } } @@ -476,6 +531,9 @@ class MisoSabFeedAttachProcessor extends AudioWorkletProcessor { } else if (data.op === "detach") { withdraw(this.delivered) this.delivered = [] + } else if (data.op === "prepare-seek") { + const result = registry.engine === null ? { kind: "refused", result: RESULT_WRONG_STATE } : registry.engine.prepareSharedSeeks(this.delivered, data.seeks) + this.port.postMessage({ op: "seek-prepared", requestId: data.requestId, ...result }) } } } diff --git a/sdk/src/browser/pcm-feed.ts b/sdk/src/browser/pcm-feed.ts index 9d375bbf9..54ab1fc54 100644 --- a/sdk/src/browser/pcm-feed.ts +++ b/sdk/src/browser/pcm-feed.ts @@ -1,19 +1,21 @@ import { BUNDLED_ENGINE_ASSETS } from "../assets.ts"; import { MisoUsageError } from "../core/errors.ts"; -import { MSB1_CONTROL, MSB1_CONTROL_BYTES, createMsb1Ring } from "./pcm-ring.ts"; +import { MSB1_CONTROL, MSB1_CONTROL_BYTES, MSB1_CONTROL_I64_OFFSET, createMsb1Ring } from "./pcm-ring.ts"; -export type PcmFeedOperation = "moduleLoad" | "nodeCreate" | "attachPost" | "readyTimeout" | "closed"; +export type PcmFeedOperation = "moduleLoad" | "nodeCreate" | "attachPost" | "readyTimeout" | "closed" + | "prepareState" | "prepareBusy" | "preparePost" | "prepareTimeout" | "prepareSuperseded" | "prepareRefused"; export class PcmFeedError extends Error { readonly operation: PcmFeedOperation; - constructor(operation: PcmFeedOperation, message: string, cause?: unknown) { - super(message, { cause }); this.name = "PcmFeedError"; this.operation = operation; + readonly result: number | undefined; + constructor(operation: PcmFeedOperation, message: string, cause?: unknown, result?: number) { + super(message, { cause }); this.name = "PcmFeedError"; this.operation = operation; this.result = result; } } -export interface FeedPort { postMessage(message: unknown): void } +export interface FeedPort { postMessage(message: unknown): void; onmessage?: ((event: MessageEvent) => void) | null } export interface FeedNode { readonly port: FeedPort; disconnect(): void } -export interface FeedContext { readonly audioWorklet: { addModule(url: string): Promise } } +export interface FeedContext { readonly audioWorklet: { addModule(url: string): Promise }; readonly state?: string } export interface FeedNodeOptions { readonly numberOfInputs: number; readonly numberOfOutputs: number } export interface FeedSource { readonly sourceId: string; readonly channels: 1 | 2 } export interface FeedOptions { @@ -28,6 +30,14 @@ export interface EngineFeed { readonly rings: readonly SharedArrayBuffer[]; readonly state: "pending" | "active" | "closed"; ready(options?: { readonly timeoutMs?: number; readonly now?: () => number; readonly wait?: (ms: number) => Promise }): Promise; + /** Prepare the producer's published seeks while suspended, freeing stale consumer slots. + * Await the producer's seek acknowledgement before calling, and serialize this handoff + * against other producer seek commands. The snapshot/post occurs before the first await; + * a later seek may supersede the pending request, but an in-progress publication is not input. + * Does not render or supply PCM. Refill after this resolves and before resuming. + * One operation may be outstanding; a newer published seek rejects the old proof. + * Timeout closes the feed, preventing an unobserved late acknowledgement from granting readiness. */ + prepareSeek(options?: { readonly timeoutMs?: number }): Promise; close(): void; } @@ -49,9 +59,25 @@ export function attachEngineFeed(options: FeedOptio let signalTerminal!: () => void; const terminal = new Promise((resolve) => { signalTerminal = resolve; }); let terminalSignaled = false; + let requestId = 0; + let pending: { id: number; seeks: SeekSnapshot[]; finish: (error?: PcmFeedError) => void } | undefined; + node.port.onmessage = ({ data }: MessageEvent): void => { + if (data?.op !== "seek-prepared" || data.requestId !== pending?.id || pending === undefined) return; + if (options.context.state !== "suspended") { + pending.finish(new PcmFeedError("prepareState", "AudioContext resumed during seek preparation")); + } else if (data.kind === "superseded" || !sameSeeks(rings, pending.seeks)) { + pending.finish(new PcmFeedError("prepareSuperseded", "PCM seek changed during preparation")); + } else if (data.kind !== "confirmed" || !Array.isArray(data.seeks) || !equalSeeks(data.seeks, pending.seeks)) { + pending.finish(new PcmFeedError("prepareRefused", "PCM consumer refused seek preparation", undefined, typeof data.result === "number" ? data.result : undefined)); + } else { + pending.finish(); + } + }; const close = (): void => { if (state === "closed") return; state = "closed"; release(rings); + pending?.finish(new PcmFeedError("closed", "Engine feed is closed")); + node.port.onmessage = null; if (!terminalSignaled) { terminalSignaled = true; signalTerminal(); } try { node.port.postMessage({ op: "detach" }); } catch { /* context already closed */ } try { node.disconnect(); } catch { /* never connected */ } @@ -74,10 +100,48 @@ export function attachEngineFeed(options: FeedOptio } if (state === "closed") throw new PcmFeedError("closed", "Engine feed is closed"); }, + async prepareSeek(settings = {}): Promise { + if (state === "closed") throw new PcmFeedError("closed", "Engine feed is closed"); + if (options.context.state !== "suspended" || !rings.every(attached)) throw new PcmFeedError("prepareState", "PCM seek preparation requires an attached feed and suspended context"); + if (pending !== undefined) throw new PcmFeedError("prepareBusy", "PCM seek preparation is already pending"); + const timeoutMs = settings.timeoutMs ?? 2_000; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new MisoUsageError("prepareSeek timeoutMs must be finite and positive"); + const seeks = rings.map(seekSnapshot); + if (seeks.some((seek) => seek.epoch === 0 || seek.generation === 0n) || !sameSeeks(rings, seeks)) throw new PcmFeedError("prepareSuperseded", "PCM seek is unpublished or changing"); + if (rings.length === 0) return; + await new Promise((resolve, reject) => { + const id = ++requestId; + const timer = setTimeout(() => { + if (pending?.id !== id) return; + pending.finish(new PcmFeedError("prepareTimeout", "PCM seek preparation timed out")); + close(); + }, timeoutMs); + pending = { id, seeks, finish(error) { + clearTimeout(timer); + pending = undefined; + if (error === undefined) resolve(); else reject(error); + } }; + try { node.port.postMessage({ op: "prepare-seek", requestId: id, seeks }); } + catch (cause) { pending?.finish(new PcmFeedError("preparePost", "PCM seek preparation could not be posted", cause)); close(); } + }); + }, close, }; } +interface SeekSnapshot { readonly epoch: number; readonly generation: bigint; readonly frame: bigint } +function seekSnapshot(ring: SharedArrayBuffer): SeekSnapshot { + const control = new Int32Array(ring, 0, MSB1_CONTROL_BYTES / 4); + const wide = new BigInt64Array(ring, MSB1_CONTROL_I64_OFFSET, 2); + return { epoch: Atomics.load(control, MSB1_CONTROL.SEEK_EPOCH), generation: Atomics.load(wide, 0), frame: Atomics.load(wide, 1) }; +} +function equalSeeks(actual: readonly SeekSnapshot[], expected: readonly SeekSnapshot[]): boolean { + return actual.length === expected.length && actual.every((seek, index) => seek?.epoch === expected[index]!.epoch && seek.generation === expected[index]!.generation && seek.frame === expected[index]!.frame); +} +function sameSeeks(rings: readonly SharedArrayBuffer[], expected: readonly SeekSnapshot[]): boolean { + return equalSeeks(rings.map(seekSnapshot), expected); +} + function release(rings: readonly SharedArrayBuffer[]): void { for (const ring of rings) Atomics.store(new Int32Array(ring, 0, MSB1_CONTROL_BYTES / 4), MSB1_CONTROL.WRITER_STATE, 0); } diff --git a/sdk/test/browser-defaults-evals.mjs b/sdk/test/browser-defaults-evals.mjs index 3ab7c877d..cbffbaa6c 100644 --- a/sdk/test/browser-defaults-evals.mjs +++ b/sdk/test/browser-defaults-evals.mjs @@ -30,14 +30,14 @@ test("scratch succeeds once after ready, correlates and ignores late events", as worker.emit("message", { ...result, requestId: 2 }); worker.emit("message", result); assert.equal(await pending, shape); worker.emitHistorical("message", result); worker.assertClosed(); }); -for (const phase of ["handshake", "request"]) { +for (const scratchStage of ["handshake", "request"]) { for (const fault of ["timeout", "abort", "error", "messageerror", "post", "reject"]) { - if (phase === "handshake" && ["post", "reject"].includes(fault)) continue; - test(`scratch closes on ${phase} ${fault}`, async () => { + if (scratchStage === "handshake" && ["post", "reject"].includes(fault)) continue; + test(`scratch closes on ${scratchStage} ${fault}`, async () => { const worker = new FakeWorker(); const controller = new AbortController(); const reason = new Error("stop"); const pending = boot(worker, { signal: controller.signal }); if (fault === "post") worker.onPost = () => { throw reason; }; - if (phase === "request") worker.emit("message", { type: "worker-ready" }); + if (scratchStage === "request") worker.emit("message", { type: "worker-ready" }); if (fault === "abort") controller.abort(reason); if (fault === "error") worker.emit("error", { error: reason }); if (fault === "messageerror") worker.emit("messageerror", {}); diff --git a/sdk/test/browser-pcm-evals.mjs b/sdk/test/browser-pcm-evals.mjs index 03150d5ac..3d49e04cc 100644 --- a/sdk/test/browser-pcm-evals.mjs +++ b/sdk/test/browser-pcm-evals.mjs @@ -18,9 +18,61 @@ import { } from "../src/browser/pcm-ring.ts"; import { attachEngineFeed, PcmFeedError, prepareEngineFeed } from "../src/browser/pcm-feed.ts"; import { BUNDLED_ENGINE_ASSETS } from "../src/assets.ts"; +import { MisoEngineAsset } from "../src/core/asset.ts"; +import { WasmBoundary } from "../src/core/boundary.ts"; +import { constantValue } from "../src/core/abi.ts"; +import { moduleBytes, sessionDocument } from "./support.mjs"; const context = { audioWorklet: { addModule: async () => {} } }; +test("actual Wasm paused seek accepts target PCM before the first quantum with old queues full", async () => { + const asset = await MisoEngineAsset.load(await moduleBytes()); + const document = new TextEncoder().encode(sessionDocument({ frames: 480_000 })); + const options = { sourceRingFrames: 512 }; + const engine = await WasmBoundary.boot(asset, document, options); + const oracle = await WasmBoundary.boot(asset, document, options); + try { + const shape = engine.shape(), sourceId = shape.sources[0].id, quantum = shape.quantumFrames; + const oldPlanes = [new Float32Array(quantum).fill(0.25), new Float32Array(quantum).fill(-0.25)]; + for (let block = 0; block < options.sourceRingFrames / quantum; block += 1) { + assert.equal(engine.submitSource({ sourceId, generation: 1n, startFrame: BigInt(block * quantum), planes: oldPlanes, endOfRegion: false }).ok, true); + } + assert.equal(engine.submitSource({ sourceId, generation: 1n, startFrame: BigInt(options.sourceRingFrames), planes: oldPlanes, endOfRegion: false }).code, "backpressure"); + const ring = createMsb1Ring({ sourceId, channels: 2, frameCapacity: quantum, capacity: 64 }); + const writer = new Msb1RingWriter(ring); + writer.engage(1n); + for (let block = 0; block < writer.capacity; block += 1) { + const planes = writer.reserve(quantum); + planes[0].set(oldPlanes[0]); planes[1].set(oldPlanes[1]); + writer.commit({ generation: 1n, startFrame: BigInt(options.sourceRingFrames + block * quantum), frames: quantum, endOfRegion: false }); + } + writer.seek(2n, 10_000n); + assert.equal(writer.occupancy, writer.capacity, "the SAB still holds a full stale generation"); + const before = engine.status(); + const seek = { sourceId, generation: 2n, sourceFrame: 10_000n }; + assert.equal(engine.seekSource(seek).ok, true); + assert.equal(engine.status().nextAbsoluteSample, before.nextAbsoluteSample); + assert.equal(engine.status().renderedQuanta, before.renderedQuanta); + const target = { + sourceId, generation: 2n, startFrame: 10_000n, endOfRegion: false, + planes: [Float32Array.from({ length: quantum }, (_, i) => (i + 1) / 256), Float32Array.from({ length: quantum }, (_, i) => -(i + 1) / 512)], + }; + // This is the Rust prerequisite, not a claim that the SDK control port already + // releases SAB slots: direct admission must work even before the next render. + const admitted = engine.submitSource(target); + const first = engine.render(quantum); + assert.equal(oracle.seekSource(seek).ok, true); + assert.equal(oracle.submitSource(target).ok, true); + const expected = oracle.render(quantum); + assert.ok(expected.left.some((sample) => sample !== 0)); + assert.deepEqual(first, expected, `first target quantum; admission was ${admitted.code}`); + assert.equal(admitted.ok, true); + writer.release(); + } finally { + engine.dispose(); oracle.dispose(); + } +}); + function controls(ring) { return new Int32Array(ring, 0, MSB1_CONTROL_BYTES / 4); } function headers(ring, capacity) { return new Int32Array(ring, MSB1_HEADER_OFFSET, capacity * MSB1_SLOT_HEADER_BYTES / 4); } function headers64(ring, capacity) { return new BigInt64Array(ring, MSB1_HEADER_OFFSET, capacity * MSB1_SLOT_HEADER_BYTES / 8); } @@ -293,6 +345,139 @@ function preludeHarness(source, { tracking = false } = {}) { return { sandbox, registrations, allocations, arm: () => { armed = true; } }; } +test("actual Wasm control-port preparation releases full stale SAB before exact first target render", async () => { + const source = await readFile(new URL("../src/browser-assets/miso-engine-v1-pcm-feed-worklet.js", import.meta.url), "utf8"); + const asset = await MisoEngineAsset.load(await moduleBytes()); + const instance = await asset.instantiate(); + let handle; + const exports = { ...instance.exports, miso_engine_web_v1_boot(...args) { handle = instance.exports.miso_engine_web_v1_boot(...args); return handle; } }; + const document = new TextEncoder().encode(sessionDocument({ frames: 480_000 })); + const boundary = await WasmBoundary.boot({ instantiate: async () => ({ exports }) }, document, { sourceRingFrames: 512 }); + const { sandbox, registrations } = preludeHarness(source); + let first; + class Engine { + constructor() { + this.quantumFrames = 128; this.maximumSourceChannels = 2; + this.exports = exports; this.handle = handle; this.memoryBuffer = exports.memory.buffer; + this.sourceIdPointer = exports.miso_engine_web_v1_buffer_ptr(handle, constantValue("bufferKinds", "sourceId")); + this.sourceIdCapacity = exports.miso_engine_web_v1_buffer_capacity(handle, constantValue("bufferKinds", "sourceId")); + this.sourcePcm = new Float32Array(this.memoryBuffer, exports.miso_engine_web_v1_buffer_ptr(handle, constantValue("bufferKinds", "sourcePcm")), 256); + this.ready = true; this.disposed = false; this.stickyResult = 0; + } + process() { first = boundary.render(128); return true; } + } + sandbox.registerProcessor("miso-engine-v1-audio-worklet", Engine); + const engine = new (registrations.get("miso-engine-v1-audio-worklet"))(); + const attach = new (registrations.get("miso-sab-feed-attach"))(); + const port = { onmessage: null, postMessage(data) { queueMicrotask(() => attach.port.onmessage({ data })); } }; + attach.port.postMessage = (data) => queueMicrotask(() => port.onmessage?.({ data })); + const suspended = { ...context, state: "suspended" }; + const feed = attachEngineFeed({ context: suspended, sources: [{ sourceId: "s", channels: 2 }], quantumFrames: 128, createNode: () => ({ port, disconnect() {} }) }); + try { + await feed.ready(); + const writer = new Msb1RingWriter(feed.rings[0]); writer.engage(1n); + const old = [new Float32Array(128).fill(0.25), new Float32Array(128).fill(-0.25)]; + for (let index = 0; index < 4; index++) assert.equal(boundary.submitSource({ sourceId: "s", generation: 1n, startFrame: BigInt(index * 128), planes: old, endOfRegion: false }).ok, true); + assert.equal(boundary.submitSource({ sourceId: "s", generation: 1n, startFrame: 512n, planes: old, endOfRegion: false }).code, "backpressure"); + for (let index = 0; index < writer.capacity; index++) { + const planes = writer.reserve(128); planes[0].set(old[0]); planes[1].set(old[1]); + writer.commit({ generation: 1n, startFrame: BigInt(512 + index * 128), frames: 128, endOfRegion: false }); + } + writer.seek(2n, 10_000n); + await feed.prepareSeek(); + assert.equal(suspended.state, "suspended"); + assert.equal(writer.occupancy, 0); + assert.equal(boundary.status().nextAbsoluteSample, 0n); + const c = controls(feed.rings[0]); + assert.equal(c[MSB1_CONTROL.STALE], 64); + assert.equal(c[MSB1_CONTROL.DRAIN_BLOCKS], 0); + assert.equal(c[MSB1_CONTROL.UNDERRUNS], 0); + assert.equal(c[MSB1_CONTROL.SEEKS_APPLIED], 1); + const target = [Float32Array.from({ length: 128 }, (_, i) => (i + 1) / 256), Float32Array.from({ length: 128 }, (_, i) => -(i + 1) / 512)]; + for (let index = 0; index < 4; index++) { + const planes = writer.reserve(128); planes[0].set(target[0]); planes[1].set(target[1]); + writer.commit({ generation: 2n, startFrame: BigInt(10_000 + index * 128), frames: 128, endOfRegion: false }); + } + await feed.prepareSeek(); // Same proof retains all fresh queued work. + assert.equal(writer.occupancy, 4); + engine.process([], []); + assert.deepEqual(first.left, target[0]); assert.deepEqual(first.right, target[1]); + assert.equal(c[MSB1_CONTROL.UNDERRUNS], 0); + assert.equal(c[MSB1_CONTROL.SUBMITTED_GENERATION_TAG], 2); + assert.equal(c[MSB1_CONTROL.SEEKS_APPLIED], 1); + } finally { feed.close(); boundary.dispose(); } +}); + +test("prepareSeek bounds requests and rejects supersession, refusal, close and timeout", async () => { + const requests = []; + const port = { onmessage: null, postMessage(message) { + if (message.op === "attach") for (const ring of message.rings) Atomics.store(controls(ring), MSB1_CONTROL.ATTACHED, 1); + else if (message.op === "prepare-seek") requests.push(message); + } }; + const suspended = { ...context, state: "suspended" }; + const feed = attachEngineFeed({ context: suspended, sources: [{ sourceId: "s", channels: 1 }], quantumFrames: 4, createNode: () => ({ port, disconnect() {} }) }); + const writer = new Msb1RingWriter(feed.rings[0]); writer.engage(1n); writer.seek(2n, 4n); + const reply = (message, changes = {}) => port.onmessage?.({ data: { op: "seek-prepared", requestId: message.requestId, kind: "confirmed", seeks: message.seeks, ...changes } }); + const first = feed.prepareSeek(); + await assert.rejects(feed.prepareSeek(), (error) => error.operation === "prepareBusy"); + assert.equal(requests.length, 1); + writer.seek(0x1_0000_0002n, 8n); // Same low-word tag still supersedes the full identity. + reply(requests[0]); + await assert.rejects(first, (error) => error.operation === "prepareSuperseded"); + const refused = feed.prepareSeek(); reply(requests[1], { kind: "refused", result: 6 }); + await assert.rejects(refused, (error) => error instanceof PcmFeedError && error.operation === "prepareRefused" && error.result === 6); + const pending = feed.prepareSeek(); feed.close(); + await assert.rejects(pending, (error) => error.operation === "closed"); + reply(requests[2]); + assert.equal(feed.state, "closed"); + const timed = attachEngineFeed({ context: suspended, sources: [{ sourceId: "s", channels: 1 }], quantumFrames: 4, createNode: () => ({ port, disconnect() {} }) }); + new Msb1RingWriter(timed.rings[0]).seek(2n, 4n); + await assert.rejects(timed.prepareSeek({ timeoutMs: 1 }), (error) => error.operation === "prepareTimeout"); + assert.equal(timed.state, "closed"); +}); + +test("control preparation never discards or applies a superseding producer generation", async () => { + const source = await readFile(new URL("../src/browser-assets/miso-engine-v1-pcm-feed-worklet.js", import.meta.url), "utf8"); + const run = runPrelude(source); + const ring = run.rings[0], writer = run.writers[0]; + writer.seek(2n, 12n); + const snapshot = { epoch: 1, generation: 2n, frame: 12n }; + const applied = []; + run.engine.exports.miso_engine_web_v1_source_seek = (_handle, _id, generation, frame) => { + applied.push([generation, frame]); + if (generation === 2n) { + writer.seek(3n, 16n); + const planes = writer.reserve(4); planes[0].fill(0.75); + writer.commit({ generation: 3n, startFrame: 16n, frames: 4, endOfRegion: false }); + } + return 0; + }; + assert.equal(run.engine.prepareSharedSeeks([ring], [snapshot]).kind, "superseded"); + assert.deepEqual(applied, [[2n, 12n]]); + assert.equal(writer.occupancy, 1, "future PCM must survive the rejected old request"); + assert.equal(run.engine.prepareSharedSeeks([ring], [{ epoch: 2, generation: 3n, frame: 16n }]).kind, "confirmed"); + assert.deepEqual(applied, [[2n, 12n], [3n, 16n]]); + assert.equal(writer.occupancy, 1); + assert.equal(run.ringControls[0][MSB1_CONTROL.STALE], 1); + assert.equal(run.ringControls[0][MSB1_CONTROL.DRAIN_BLOCKS], 0); + run.process(); + assert.equal(run.submissions[0].generation, 3n); + assert.equal(run.submissions[0].pcm[0], 0.75); + + const raced = runPrelude(source); + const racedWriter = raced.writers[0]; racedWriter.seek(2n, 12n); + const apply = raced.engine.applySharedSeek.bind(raced.engine); + let advance = true; + raced.engine.applySharedSeek = (ring, ...identity) => { + if (advance) { advance = false; racedWriter.seek(3n, 16n); } + return apply(ring, ...identity); + }; + assert.equal(raced.engine.prepareSharedSeeks([raced.rings[0]], [snapshot]).kind, "superseded"); + assert.deepEqual(raced.seeks, [[2n, 12n]], "apply the captured tuple, never mixed live epoch/generation words"); + assert.equal(raced.engine.prepareSharedSeeks([raced.rings[0]], [{ epoch: 2, generation: 3n, frame: 16n }]).kind, "confirmed"); + assert.deepEqual(raced.seeks, [[2n, 12n], [3n, 16n]]); +}); + function runPrelude(source, { tracking = false, mutate = false } = {}) { const mutated = mutate ? source.replace( "const staging = this.sourcePcm", diff --git a/sdk/test/package-tarball-smoke.mjs b/sdk/test/package-tarball-smoke.mjs index c6c7a9c51..777108b47 100644 --- a/sdk/test/package-tarball-smoke.mjs +++ b/sdk/test/package-tarball-smoke.mjs @@ -369,9 +369,35 @@ if (process.env.MISO_ENGINE_SDK_BROWSER_TOOLS) { ]); const browserRoot = resolve(consumerRoot, "browser"); await mkdir(browserRoot); - await writeFile(resolve(browserRoot, "index.html"), ''); + const seekModel = JSON.parse(builtDocument); + seekModel.sources[0].frames = "480000"; + const seekDocument = JSON.stringify(seekModel); + const target = [Float32Array.from({ length: 128 }, (_, i) => (i + 1) / 256), Float32Array.from({ length: 128 }, (_, i) => -(i + 1) / 512)]; + const oracle = await imported["./headless"].createOfflineEngine(seekDocument); + const expectedSeek = (() => { + try { + assert.equal(oracle.seekSource({ sourceId: "stem", generation: 2n, sourceFrame: 10_000n }).ok, true); + assert.equal(oracle.submitSource({ sourceId: "stem", generation: 2n, startFrame: 10_000n, planes: target, endOfRegion: false }).ok, true); + const pcm = oracle.render(); return [[...pcm.left], [...pcm.right]]; + } finally { oracle.dispose(); } + })(); + await writeFile(resolve(browserRoot, "capture.js"), ` +class Capture extends AudioWorkletProcessor { + constructor() { super(); this.sent = false; } + process(inputs, outputs) { + const input = inputs[0]; + if (!this.sent && input?.length === 2 && input[0].length === 128) { + this.sent = true; this.port.postMessage([Array.from(input[0]), Array.from(input[1])]); + } + for (let channel = 0; channel < outputs[0].length; channel++) if (input?.[channel]) outputs[0][channel].set(input[channel]); + return true; + } +} +registerProcessor('capture-first-quantum', Capture); +`); + await writeFile(resolve(browserRoot, "index.html"), ''); await writeFile(resolve(browserRoot, "main.js"), ` -import { createEngine } from '@misofm/engine/browser'; +import { createEngine, createDefaultHost, prepareEngineFeed, attachEngineFeed, Msb1RingWriter, Msb1RingObserver } from '@misofm/engine/browser'; import { BUNDLED_ENGINE_ASSETS } from '@misofm/engine/assets'; const sessionDocument = ${JSON.stringify(builtDocument)}; window.proof = []; @@ -393,6 +419,53 @@ for (const id of ['default', 'forward']) document.querySelector('#' + id).onclic window.proof.push({ id, calls, rate: engine.shape.sampleRateHz, quantum: engine.shape.quantumFrames, result: status.result, state: engine.context.state }); } catch (error) { window.bootError = String(error?.stack ?? JSON.stringify(error)); } }; +document.querySelector('#seek').onclick = async () => { + let engine, feed; + try { + engine = await createEngine({ document: ${JSON.stringify(seekDocument)}, policy: { sourceRingFrames: 512 }, createHost: async request => { + if (request.context.state === 'running') await request.context.suspend(); + await prepareEngineFeed(request.context); return createDefaultHost(request); + } }); + const context = engine.context; + feed = attachEngineFeed({ context, sources: [{ sourceId: 'stem', channels: 2 }], quantumFrames: 128 }); + await feed.ready(); + const writer = new Msb1RingWriter(feed.rings[0]); writer.engage(1n); + const old = () => [new Float32Array(128).fill(.25), new Float32Array(128).fill(-.25)]; + for (let index = 0; index < 4; index++) { + const ack = await engine.host.submitSource({ sourceId: 'stem', generation: 1n, startFrame: BigInt(index * 128), sampleRateHz: 48000, frames: 128, planes: old(), endOfRegion: false }); + if (ack.result !== 0) throw new Error('old internal queue did not fill'); + } + const refusal = await engine.host.submitSource({ sourceId: 'stem', generation: 1n, startFrame: 512n, sampleRateHz: 48000, frames: 128, planes: old(), endOfRegion: false }).catch(error => error); + if (refusal.result !== 6) throw new Error('internal queue was not full'); + for (let index = 0; index < writer.capacity; index++) { + const planes = writer.reserve(128); planes[0].fill(.25); planes[1].fill(-.25); + writer.commit({ generation: 1n, startFrame: BigInt(512 + index * 128), frames: 128, endOfRegion: false }); + } + const before = await engine.host.status(); + const beforeTime = context.currentTime; + writer.seek(2n, 10_000n); + await feed.prepareSeek(); + const after = await engine.host.status(); + const prepared = { state: context.state, timeUnchanged: context.currentTime === beforeTime, sampleUnchanged: after.nextAbsoluteSample === before.nextAbsoluteSample, occupancy: writer.occupancy }; + if (prepared.state !== 'suspended' || !prepared.timeUnchanged || !prepared.sampleUnchanged || prepared.occupancy !== 0) throw new Error('preparation rendered, advanced time or retained stale slots'); + const target = ${JSON.stringify(target.map(plane => [...plane]))}; + for (let index = 0; index < writer.capacity; index++) { + const planes = writer.reserve(128); planes[0].set(target[0]); planes[1].set(target[1]); + writer.commit({ generation: 2n, startFrame: BigInt(10_000 + index * 128), frames: 128, endOfRegion: false }); + } + await context.audioWorklet.addModule(new URL('./capture.js', import.meta.url)); + const capture = new AudioWorkletNode(context, 'capture-first-quantum', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2] }); + const first = new Promise(resolve => { capture.port.onmessage = ({ data }) => resolve(data); }); + engine.host.node.connect(capture); capture.connect(context.destination); + await context.resume(); + const pcm = await first; + await context.suspend(); + const observer = new Msb1RingObserver(feed.rings[0]); + const counters = observer.counters(); observer.close(); + window.seekProof = { prepared, pcm, counters }; + } catch (error) { window.bootError = String(error?.stack ?? JSON.stringify(error)); } + finally { feed?.close(); await engine?.close(); } +}; `); await build({ root: browserRoot, configFile: false, logLevel: "warn" }); const network = []; @@ -402,7 +475,7 @@ for (const id of ['default', 'forward']) document.querySelector('#' + id).onclic try { const bytes = await readFile(resolve(browserRoot, "dist", `.${pathname === "/" ? "/index.html" : pathname}`)); const mime = pathname.endsWith(".js") ? "text/javascript" : pathname.endsWith(".wasm") ? "application/wasm" : "text/html"; - response.writeHead(200, { "content-type": mime }); response.end(bytes); + response.writeHead(200, { "content-type": mime, "cross-origin-opener-policy": "same-origin", "cross-origin-embedder-policy": "require-corp" }); response.end(bytes); } catch { response.writeHead(404); response.end(); } }); await new Promise((accept, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", accept); }); @@ -429,9 +502,18 @@ for (const id of ['default', 'forward']) document.querySelector('#' + id).onclic assert.equal(results[1].calls.length, 1); assert.equal(results[1].calls[0].url, results[1].calls[0].expected); assert.equal(results[1].calls[0].type, "module"); + await page.locator('#seek').click(); + await page.waitForFunction(() => window.seekProof || window.bootError, undefined, { timeout: 20000 }); + assert.equal(await page.evaluate(() => window.bootError), undefined); + const seekProof = await page.evaluate(() => window.seekProof); + assert.deepEqual(seekProof.pcm, expectedSeek, 'first resumed browser quantum equals exact target PCM'); + assert.equal(seekProof.counters.stale, 64); + assert.equal(seekProof.counters.underruns, 0); + assert.equal(seekProof.counters.seeksApplied, 1); + assert.equal(seekProof.counters.submittedGenerationTag, 2); assert.deepEqual(faults, []); assert.equal(network.some(response => response.status >= 400), false); - console.log(`packed Vite/Chromium browser boot passed: ${JSON.stringify({ results, network })}`); + console.log(`packed Vite/Chromium browser boot passed: ${JSON.stringify({ results, seekProof, network })}`); } finally { await browser?.close(); await new Promise(accept => server.close(accept));