diff --git a/.github/ISSUE_SPECS/405-move-the-existing-browser-pcm-feed-into-the-typescript-sdk.md b/.github/ISSUE_SPECS/405-move-the-existing-browser-pcm-feed-into-the-typescript-sdk.md new file mode 100644 index 000000000..f5711915f --- /dev/null +++ b/.github/ISSUE_SPECS/405-move-the-existing-browser-pcm-feed-into-the-typescript-sdk.md @@ -0,0 +1,276 @@ +# Move the existing browser PCM feed into the TypeScript SDK + +**Issue:** https://github.com/misofm/engine/issues/405 +**Scope:** Sol-approved; the matching numbered issue/spec is synchronized before Luna implementation. Astra reviews the implementation PR. +**Engine baseline:** `b89b767d`, with product code and required regressions reviewed PASS by Astra at `bed7634c` for #393 (PR #398). +**Copied current adapter baseline:** `63b4ee6212287000ff85e1cfa969d385f6246d2d`; source ring/feed/prelude remain unchanged by independent OPFS #19. + +## Scope decision + +Split SDK boot defaults from PCM ingress. This issue moves only the existing engine-specific PCM ingress contract: the MSB1 shared-ring layout/writer, feed attachment lifecycle, and AudioWorklet prelude asset. The adapter remains responsible for verified storage, FLAC decode, pump scheduling, prefill, seek orchestration, and session lifecycle. SDK scratch/default-host construction remains injected exactly as it is today and belongs to a later issue. + +This is independently useful: one SDK version becomes the authority for both sides of the engine's existing browser PCM boundary, while any codec-neutral producer can fill its bounded rings. It adds no playback capability and does not change the Rust ABI, host messages, session schema, or all-stems-ready policy. + +Because delivery crosses repositories, root should create two linked stateless specs after approval: + +1. Engine/SDK authority and packed asset PR. +2. Adapter consumer migration PR, based on an exact locally packed artifact from the engine PR. + +The engine issue may close once its package and focused gates pass. The adapter issue cannot claim registry compatibility or close as deployable until the matching SDK version is published and its exact dependency/provenance can truthfully be updated. + +## Frozen contract + +- Preserve MSB1 magic/version, control/header offsets, field meanings, ring sizing, planar `f32` slots, power-of-two capacity, generation/seek publication, counters, backpressure retention, scalar tail behavior, and quantum-sized frames byte-for-byte. +- Preserve the existing prelude behavior: load before the engine worklet; wrap the registered engine processor; drain without steady-state allocation or waiting; submit through existing `miso_engine_web_v1_source_submit`/seek exports; retain a slot on result 6; drop stale generations; attach rings through the separate `miso-sab-feed-attach` port. +- Preserve `prepareEngineFeed`/`attachEngineFeed` behavior: default capacity 64, exact source ID/channel inputs, injected node factory, bounded ready timeout, attachment probe, idempotent close, writer release, detach, and disconnect. +- The SDK API is codec/storage neutral. It accepts only existing source ID, channel count, quantum, capacity, context/module URL, and node-factory inputs; it returns the existing rings/feed lifecycle and ring writer primitives. It must not accept Blob/File, FLAC, OPFS, hashes, locators, decoded-source metadata beyond ID/channels, or pump policy. +- SDK compilation currently uses ES/WebWorker libraries without the DOM global library. Define the feed's context, worklet, node, node-options, and port requirements as the smallest structural interfaces needed by the implementation. Keep injected browser factories assignable from real `BaseAudioContext`/`AudioWorkletNode` consumers without changing the SDK `tsconfig` global libraries or importing adapter types. Browser globals may be reached behind runtime feature checks/casts in the default branch; do not broaden the entire SDK type environment to DOM. +- Keep adapter public customization working: `feedWorkletModuleUrl`, `createAttachNode`, and `createPump` retain their current meanings. The adapter may keep a thin compatibility facade that delegates to SDK PCM ingress and translates SDK failures into the same existing adapter error codes/messages where tests establish that contract. It must contain no copied ring arithmetic or feed state machine. +- The adapter's pump worker remains adapter-owned but imports the SDK ring writer/size authority. The adapter `./stems` export may re-export the existing `MSB1_CONTROL` and counter type from the SDK so its current public surface does not break. +- Keep verified-all, stored-all, then prefill-before-play ordering unchanged. No progressive/no-storage path. +- Freeze one narrow SDK-native failure type in `pcm-feed.ts`, with a stable operation discriminant limited to `moduleLoad`, `nodeCreate`, `attachPost`, `readyTimeout`, and `closed`. Invalid caller shape continues to use the SDK's existing `MisoUsageError`/range conventions. The adapter facade maps the operation discriminant directly: module load to existing `capability.audio_worklet`; node creation, attach-post failure, and readiness timeout to existing `session.open`; closed readiness to existing `session.closed`. It must not parse messages, import adapter errors into the SDK, mint an ABI reason, or create a general error system. +- Preserve bounded terminal behavior at the moved seam. The baseline defects are confirmed: if initial attach `postMessage` throws, the SDK must release every owned ring's writer state and disconnect the newly owned node before rejecting; if `close()` wins while `ready()` is pending, `ready()` rejects promptly through the frozen `closed` operation rather than waiting for its deadline or resolving falsely ready. Timeout closes before rejecting. These corrections stay inside the existing feed lifecycle and add no cancellation API. + +## SDK asset decision + +Stage the prelude source JavaScript as a normal SDK-owned package asset beside the existing browser assets and expose its package-relative URL through `BUNDLED_ENGINE_ASSETS`. Preserve its provenance header from adapter `63b4ee6` and the earlier source attribution already present in the file. + +Attribute the moved TypeScript ring/feed sources to adapter `63b4ee6` and preserve the adapter's recorded earlier provenance. Update the engine repository `NOTICE`, then have the existing stage script copy that notice into `sdk/dist/NOTICE`; package smoke must assert the installed SDK carries it. Attribution applies to source and packed output, not only the prelude header. + +Do not add this hand-maintained JS file to the Rust-generated ABI artifact authority or regenerate Rust manifests. `sdk/codegen/stage-package.mjs` may copy it from an SDK source-asset directory after staging the unchanged engine artifact closure. Package smoke must prove the new file is present and addressable. The engine Wasm, generated host JS/declaration, ABI layout, parameter metadata, and their hashes remain unchanged. + +## Exact allowed paths + +### Engine / SDK authority PR + +- `.github/ISSUE_SPECS/405-move-the-existing-browser-pcm-feed-into-the-typescript-sdk.md` +- `sdk/src/browser/pcm-ring.ts` (new; name may be `ring.ts` if root prefers) +- `sdk/src/browser/pcm-feed.ts` (new; name may be `feed.ts`) +- `sdk/src/browser/index.ts` +- `sdk/src/assets.ts` +- `sdk/src/browser-assets/miso-engine-v1-pcm-feed-worklet.js` (new source asset; exact directory may follow existing SDK convention) +- `sdk/codegen/stage-package.mjs` +- `sdk/test/browser-evals.mjs` or new focused `sdk/test/browser-pcm-evals.mjs` (matched by the existing headless eval glob) +- `sdk/test/console-types.ts` or a new focused `sdk/test/browser-pcm-types.ts` +- `sdk/test/package-tarball-smoke.mjs` +- `scripts/sdk-package.sh` only if its current staged-source allowlist requires the new normal SDK asset +- `sdk/README.md` and repository `NOTICE` only for the new public export/provenance obligation; `sdk/codegen/stage-package.mjs` owns staging `NOTICE` under `dist/` + +No `crates/`, `hosts/host-web/src`, generated host/worklet files, Wasm, ABI/parameter JSON, or generated provenance TypeScript may change. + +## Objective gates + +### SDK authority + +1. A layout fixture creates representative mono/stereo rings and proves every existing byte length, offset/control word, source-ID encoding limit, slot view, generation/seek field, counter, and invalid-layout refusal equals adapter baseline. Use frozen expected values or a copied test fixture, not runtime comparison against retained duplicate production code. +2. Writer tests preserve reserve/commit/backpressure/zero-fill/seek/release behavior, including odd track/source counts and a final partial quantum. No whole-source allocation is introduced. +3. The existing feed-prelude oracle is moved to the SDK and proves submit, seek retry on result 6, stale-generation rejection, attach/detach, and no first-use/steady-drain typed-array or subview allocation. Run the existing realtime source-policy gate if it accepts this asset; otherwise add only a focused discriminator for the same rules. +4. Feed lifecycle tests prove injected node precedence, exact attach message/ring identity, empty-source readiness, bounded timeout closes/releases/detaches once, successful readiness, and repeated close safety. They reproduce the confirmed attach-post cleanup and close-during-ready defects: attach-post failure releases/disconnects before the typed SDK rejection; close makes every pending readiness call reject once with the `closed` operation. No node or ring remains active after failed attachment. +5. `BUNDLED_ENGINE_ASSETS` resolves the staged prelude from a freshly packed SDK. The packed consumer imports PCM ingress from `@misofm/engine/browser`, attaches with defaults and with the injected node/module seam, and contains no adapter dependency. The tarball contains `dist/NOTICE` with the moved-source attribution. +6. The package manifest continues to describe the unchanged generated Engine artifact closure truthfully; the normal prelude asset is separately staged and checked. Wasm, generated host/worklet JS, host declaration, ABI JSON, and parameter JSON are byte-identical to baseline. +7. Run `scripts/check-sdk-types.sh`, `scripts/check-sdk-generated.sh`, `scripts/check-sdk-headless.sh`, `scripts/sdk-package.sh check`, and the focused browser/package/tarball evals. No full Rust workspace rebuild is required beyond what these repository gates invoke. + +## Explicit exclusions + +- Default scratch worker, default host import/construction, default `AudioContext`, or a new top-level browser engine object. Those form the next independent boot-default issue. +- Request counters/brokers or any `run(requestId)` API. Issue #393 makes the host the sole allocator. +- Console receipts, coalescing, meters/telemetry behavior, readback, revisions, automation, agent APIs, structural replacement, progressive playback, alternate storage, or nonisolated transport. +- Rust, C ABI, worklet-host wire messages, source introspection, session parsing changes, and new ABI error vocabulary. +- Adapter error types inside the SDK. SDK failures use the SDK's existing usage/engine error conventions; the adapter facade performs only the narrow translation needed to preserve its established public errors. No general error subsystem is added. +- npm publication. These PRs produce local packed integration evidence and an explicit release-order blocker. + +## Review and delivery + +Root approves and numbers both linked specs before their respective implementations. Implement the engine SDK authority first and checkpoint it. Astra reviews that PR before the adapter migration starts. The adapter implementation then consumes the reviewed exact tarball; it must not overlap active issue #19. Each repository follows its own checkpoint and three-attempt rules. Success is two reviewable PRs with truthful local packed integration evidence, not a registry release claim. + +## Root implementation decisions + +This numbered issue owns SDK authority only; adapter production changes are forbidden here and will have their own linked spec after SDK review. `prepareEngineFeed` defaults its optional module URL to the packaged PCM prelude; the explicit URL still wins. Use the concrete names `pcm-ring.ts`, `pcm-feed.ts`, and `miso-engine-v1-pcm-feed-worklet.js` above. The narrow feed-operation failure type frozen above is allowed; broader errors remain excluded. + +Reuse the exact pinned CI artifact closure already qualified for #393 at `/private/tmp/dx-393-current-artifacts`; all six generated artifact bytes must remain unchanged by this issue. Darwin's independently reproduced baseline digest mismatch is documented in #393/#333/#345 and must not cause a repin. + +Implementation starts only after the current OPFS tranche is checkpointed. Implement all minimum focused regressions for this move in the same coherent tranche before the first focused-green handoff; root commits exact paths before any further implementation. A pending gate is explicitly pending, never PASS by inference. Existing PR #398 may carry this bounded SDK issue alongside the separately completed #393; Astra reviews the new issue diff and final combined PR. No npm publication is authorized. + +## Luna attempt 1 — source checkpoint, review pending + +Luna moved the current ring/feed/prelude into the approved SDK files, exported the codec-neutral primitives and asset URL, staged the separate prelude and NOTICE, and reported passing TypeScript/build/package checks against `/private/tmp/dx-393-current-artifacts`. The six generated engine artifact files remain unchanged. This is a recoverable source checkpoint only: required focused ring/feed, allocation, DOM-consumer and packed-consumer regressions remain pending, as does Astra review. A direct browser-eval invocation omitted its artifact environment; use the existing `scripts/check-sdk-headless.sh ARTIFACT_DIRECTORY` wrapper for proper qualification. No PASS or completed SDK ownership claim is made yet. + +## Luna attempt 1 — focused test checkpoint + +Added the focused PCM eval file and packed-consumer asset/export/NOTICE assertions. Luna reports `scripts/check-sdk-headless.sh /private/tmp/dx-393-current-artifacts`: 137 pass, one platform skip, zero failures; package check passes with the separate prelude and NOTICE and unchanged six generated artifacts. Dedicated Astra review remains pending and will assess whether every frozen lifecycle, layout, allocation and real DOM-consumer discriminator is complete. No review PASS is inferred from the suite count. + +## Astra attempt 1 verdict — FAIL (2026-09-05) + +Dedicated Astra reviewed pushed `a86f3cf4`. Required corrections: close must reject every pending ready caller even when its injected wait is blocked; restore the original monotonic timeout clock; preserve ID/channels source inputs and real DOM context/factory assignability; complete the frozen ring, lifecycle, runtime prelude allocation and packed consumer regressions; preserve earlier source provenance in NOTICE. No prelude algorithm change is warranted: Astra independently verified copied bytes, bounded submit/seek/backpressure behavior and zero instrumented render allocations. Six generated artifacts remain identical. Advertised headless (137 pass, 1 skip) and package gates passed but do not satisfy these missing contracts. + +This is the first failed implementation attempt. The full review is attached to PR #398. A separately briefed attempt 2 must keep this issue's product scope unchanged. PR #398 also has independent required CI failures (environment vocabulary and a pre-existing real-clock telemetry assertion), which are not authorized implementation paths in this issue. No merge or release readiness is claimed. + +## Attempt 2 authorization (2026-09-05) + +Sol supplied the bounded revision brief and root approved it after the adapter qualification attempt stopped and its evidence was checkpointed. Luna implements one coherent second attempt; dedicated Astra reviews the exact pushed result. The original contract and gates remain frozen. + +## Required product corrections + +1. In `sdk/src/browser/pcm-feed.ts`, make the feed source input exactly the existing public input shape: source ID and channel count. `attachEngineFeed` derives `frameCapacity` from `quantumFrames` and ring capacity from `capacityChunks ?? 64`; callers must not provide `Msb1RingLayout` or its `frameCapacity`/`capacity` fields. +2. Keep the SDK free of DOM globals while preserving real browser assignability. Parameterize the feed options/function over the caller's structural context (or use an equivalently narrow context-preserving signature) so a `BaseAudioContext` and an existing factory typed `(BaseAudioContext, string, AudioWorkletNodeOptions) => AudioWorkletNode` pass strict consumer typechecking. `FeedNodeOptions` may remain the minimal number-of-inputs/outputs shape because that value is assignable to `AudioWorkletNodeOptions`. Do not add `DOM` to `sdk/tsconfig.json`, import adapter types, require consumer casts, widen the SDK with unrelated browser interfaces, or add a second factory API. +3. Make `close()` synchronously signal every `ready()` call already pending. Two callers blocked in injected waits must promptly reject once with `PcmFeedError.operation === "closed"`; they cannot wait for their own timer/wait promise to settle. A small feed-private terminal promise/notification raced with each wait is sufficient. Do not expose cancellation, a signal, a pending-call registry, or another public state. +4. Preserve timeout ownership: the caller that observes its deadline closes the feed and rejects with `readyTimeout`; other pending callers awakened by that close reject with `closed`. Release every ring, attempt one detach, and disconnect once. Repeated close remains safe. Restore the adapter baseline's monotonic default `performance.now()`; retain injected `now`/`wait` only as the existing test/composition seam. +5. Preserve the working attach-post cleanup. If initial attach publication throws after the port has observed and engaged the rings, all writer states are zero before disconnect and the caller receives `attachPost`, even when detach and disconnect also throw. Cleanup exceptions must not replace the typed primary failure. +6. Expand repository `NOTICE` to retain all relevant lineage already recorded by adapter `63b4ee6212287000ff85e1cfa969d385f6246d2d`: the MSB1 writer came from the authorized `misofm/engine` baseline `bd7f330a9773ce43bb077f0e6d5c8fc30fe9e27c`; the MSB1 allocation/reader contract and AudioWorklet feed prelude/attach mechanism came from authorized `misofm/app` `7485693e9bbcf2f65a91a4e5950e22d678d99062`; this SDK move came through adapter `63b4ee6`. Keep the prelude header intact and prove staged `dist/NOTICE` contains the exact source identities. Attribution must cover the TypeScript ring/feed sources as well as the JS asset. + +No rewrite of `pcm-ring.ts` or the prelude is expected. If focused evidence exposes a real mismatch with the copied `63b4ee6` contract, stop for a scope amendment rather than hardening or redesigning it opportunistically. + +## Required focused regressions + +Use the existing `node:test`/SDK eval setup. Port the current adapter behavior into `sdk/test/browser-pcm-evals.mjs` where it is part of the moved contract. In particular, use `/private/tmp/dx-405-astra-prelude.mjs` and adapter `tests/feed-prelude.test.ts` at `63b4ee6` as concrete verified fixtures; make paths repository-relative and consolidate helpers instead of creating another runner or framework. + +### Ring and writer boundary + +- Freeze literal mono and stereo examples: total byte length, 128-byte control area, ID offset/capacity, 256-byte slot-header offset, calculated PCM offset, and every initialized control word. Decode and compare the source ID bytes, including a multibyte ID; accept exactly 128 UTF-8 bytes and reject 129, proving the boundary is bytes rather than JavaScript characters. +- After `engage`, `reserve` and `commit`, inspect the actual control words, slot `Int32`/`BigInt64` headers and planar PCM offsets. Assert sequence, signed generation tag, full generation, start frame, frame count, end-of-region flag, write index and wrote counter. A final partial quantum must leave its unwritten lane samples at positive zero. +- Fill a capacity-two ring, prove the next reserve returns `null`, increments overflow exactly once, and leaves queued slot/header/PCM data and write index intact. Advance the public shared read index as the consumer would, reuse the wrapped slot, and prove reserve zero-fills both mono/stereo planes before reuse. +- Assert seek publication (`generation`, frame, generation tag and epoch), release, occupancy across wrap, and the public constructor's existing malformed-buffer refusals (non-shared input, wrong magic/version, invalid power-of-two/zero shape). Do not invent new validation semantics or copy the adapter reader into SDK production. + +### Feed lifecycle + +- Prove default and explicit module URLs and typed `moduleLoad`; prove typed `nodeCreate` and that an injected factory wins even when a usable default `AudioWorkletNode` is present. +- Capture the exact `{ op: "attach", rings }` message. The array must contain the same ring objects returned by the feed, in source order; use three mono/stereo/mono sources to cover an odd count. Assert derived quantum/capacity control words and default capacity 64. +- Prove empty-source readiness and ordinary successful readiness by publishing `ATTACHED` on all captured rings. Neither case waits past its required state. +- Reproduce Astra's blocked-wait case exactly: start two `ready()` calls whose injected waits never resolve, observe both entered, call `close()`, and bound the observation with the test harness. Both reject `closed`; neither resolves later. Writer release, one detach and one disconnect are asserted. Repeated close changes none of those counts. +- For timeout, use the injected deterministic clock. The timing-out caller rejects `readyTimeout`, a second independently pending caller rejects `closed`, and release/detach/disconnect each occur once. +- For attach-post failure, have the captured attach handler set every ring's writer state nonzero and then throw. Assert every state is zero at disconnect time and after rejection. Make detach and disconnect throw in this fixture and still require the original `attachPost` error. This makes cleanup observable rather than relying on rings' initial zero state. + +### Prelude behavior and realtime discriminator + +- Execute the moved asset in the existing VM-style AudioWorklet sandbox, register the wrapped engine and attach processors, and run three mono/stereo/mono rings. Submit real nonzero PCM with a partial tail and assert exact planar staging plus positive-zero tail values and submit metadata. +- Prove result-6 submit retains the slot without a refusal count, a later success drains it, result-6 seek retries, the successful newer seek drops the queued stale generation, underrun accounting advances, and attach/detach toggles all rings. Assert the relevant submitted/refused/stale/seek/occupancy counters, not only that methods were called. +- Instrument all typed-array constructors plus `subarray` and `slice` only while the first successful drain and later steady/partial drains run. Construction and attachment happen before arming. Require zero tracked events. Run one in-memory source mutation inserting `new Float32Array(4)` into `drainSharedRing` through the same helper and require the tracker to turn red. The existing narrow source checks may stay as complementary evidence; they are not the allocation proof. +- Do not edit the copied prelude to make the test pass, build a browser timing harness, or introduce a generic allocation-testing framework. + +### Packed public consumer + +- Extend the existing fresh tarball TypeScript consumer in `sdk/test/package-tarball-smoke.mjs` to import `prepareEngineFeed`, `attachEngineFeed` and the needed PCM writer primitive from `@misofm/engine/browser`. Under its existing strict `lib.dom` program, call the API with `{ sourceId, channels }`, a declared `BaseAudioContext`, and a factory explicitly typed `(BaseAudioContext, string, AudioWorkletNodeOptions) => AudioWorkletNode`. This exact fixture must catch both attempt-1 declaration failures without casts or SDK DOM-library changes. +- In the packed runtime portion, use small structural fakes to load the packaged default prelude URL, then attach once with the default module URL and injected node seam. Assert the explicit module URL still wins, ring identity/default capacity, successful readiness and idempotent close. Do not instantiate a real audio device or duplicate all source-level lifecycle cases here. +- Assert the packaged prelude is separately present/addressable and `dist/NOTICE` contains adapter, earlier Engine and earlier app provenance. Keep the manifest's six generated Engine artifacts exact and compare all six bytes with `/private/tmp/dx-393-current-artifacts`. + +These tests qualify externally supported behavior: public ring layout/writer semantics, feed terminal lifecycle and browser assignability, the shipped prelude's synchronous drain, and fresh-package consumption. Do not add exhaustive corrupt-buffer fuzzing, internal helper snapshots, duplicate source and tarball suites, a second adapter reader, full browser/device matrices, new performance machinery, or byte pins for prose/tests. Existing current-adapter cases may be moved or adapted only where they directly prove this boundary. + +## Exact paths for attempt 2 + +- `.github/ISSUE_SPECS/405-move-the-existing-browser-pcm-feed-into-the-typescript-sdk.md` — attempt-2 evidence only +- `sdk/src/browser/pcm-feed.ts` +- `sdk/test/browser-pcm-evals.mjs` +- `sdk/test/package-tarball-smoke.mjs` +- `NOTICE` + +Use `sdk/test/browser-pcm-types.ts` only if a source-level non-DOM type assertion cannot live cleanly in the existing type corpus; the strict real-DOM declaration proof still belongs in the fresh packed consumer. `sdk/src/browser/pcm-ring.ts`, the prelude asset, barrels, assets table and staging script should remain unchanged from attempt 1 unless a direct gate failure proves a correction is necessary. No adapter, Rust, host/worklet, generated artifact, ABI/parameter JSON, SDK `tsconfig`, package dependency, workflow or CI-tooling path is allowed. + +## Gates and evidence + +Run once after the coherent revision is complete: + +1. Focused PCM eval through the repository's existing headless wrapper with `/private/tmp/dx-393-current-artifacts`; record focused subtest names/counts, including the blocked-ready and allocation-mutation discriminators. +2. `bash scripts/check-sdk-types.sh` and show the fresh packed strict DOM consumer has no diagnostics. A local red mutation restoring `sources: Msb1RingLayout[]` or fixing the factory context to `FeedContext` must make that consumer fail. +3. `bash scripts/check-sdk-generated.sh`. +4. `bash scripts/check-sdk-headless.sh /private/tmp/dx-393-current-artifacts`. +5. From `sdk`, `npm run check:package -- /private/tmp/dx-393-current-artifacts` (or the exact repository wrapper it invokes), including the fresh tarball runtime/type consumer. +6. Byte-compare all six generated artifacts against `/private/tmp/dx-393-current-artifacts`; record the prelude and NOTICE separately and do not repin the documented Darwin mismatch. +7. `git diff --check` and an exact-path diff audit. + +Update issue #405 with Luna attempt-2 evidence but make no PASS claim. Root commits the exact coherent paths before any new implementation tranche. Dedicated Astra then reviews the pushed exact commit and reruns the reproduced blocked-ready, DOM consumer, attach cleanup, runtime allocation/red-mutation and packed-consumer gates. No adapter migration begins before that verdict. + +## Luna attempt 2 — source and focused regression checkpoint (2026-09-05) + +Luna corrected the public source shape to `{ sourceId, channels }`, made the feed factory context generic so a strict real-DOM `BaseAudioContext`/`AudioWorkletNode` factory assigns without casts, restored the monotonic `performance.now()` default, and added a private terminal notification so every already-blocked `ready()` rejects promptly with `closed`. Timeout ownership remains with the caller that observes its deadline; attach-post cleanup releases all ring writer states before best-effort detach/disconnect, preserving the typed primary `attachPost` failure. Repository `NOTICE` now carries the adapter, authorized Engine, and authorized app source identities and the staged package smoke checks all three exact commits. + +The four focused `browser-pcm-evals.mjs` subtests now cover literal mono/stereo layout bytes and UTF-8 limits, writer headers/counters/backpressure/wrap zero-fill/seek/release/malformed buffers, default and explicit prelude URLs, typed factory precedence, odd-source attach identity, empty/success readiness, two blocked callers, timeout ownership, attach-post cleanup under throwing cleanup calls, and the copied prelude's odd mono/stereo drain behavior. The prelude scenario instruments all typed-array constructors plus `subarray`/`slice` only after setup and records zero events across first/steady/partial drains; an in-memory `new Float32Array(4)` mutation turns the tracker red. + +Evidence from this checkpoint: `bash scripts/check-sdk-types.sh` PASS; `bash scripts/check-sdk-generated.sh` PASS; `node --test sdk/test/browser-pcm-evals.mjs` PASS, 4/4; `bash scripts/check-sdk-headless.sh /private/tmp/dx-393-current-artifacts` PASS, 137 pass / 1 skip / 0 fail; `npm run check:package -- /private/tmp/dx-393-current-artifacts` PASS, including the fresh strict DOM declaration consumer and packed runtime consumer. All six generated Engine artifacts compare byte-identically with `/private/tmp/dx-393-current-artifacts`; the prelude remains `d81c2159b6ca088df97e76f09c4803540e6451920cc3af1b091336fa1bdba58d`. This is an implementation checkpoint for dedicated Astra review, not a PASS claim. + +## Review stop questions + +- Can `close()` settle every already-blocked `ready()` without waiting for the injected wait, and does timeout preserve its own `readyTimeout` result? +- Can an ordinary `{ sourceId, channels }` plus real DOM factory typecheck against packed declarations without casts? +- Does failure cleanup demonstrably release engaged rings before disconnect, even when cleanup calls throw? +- Does runtime evidence prove first and steady drain allocation behavior and turn red for an inserted typed-array allocation? +- Does the installed package actually use PCM ingress and retain every relevant provenance source while its six generated artifacts remain unchanged? +- Did any work expand into adapter behavior, progressive playback, boot defaults, storage/codec/pump policy, host messages, generated artifacts, or new test infrastructure? + +## Dedicated Astra attempt 2 verdict — FAIL (2026-09-05) + +Product corrections and packed DOM/runtime consumption pass independent review at `c3c2c972`. Three concrete escaping mutations prevent PASS: later populated-drain allocation, full-ring PCM corruption, and release after disconnect. The frozen related ring/lifecycle assertions remain incomplete. Full review and reproductions are attached to PR #398. Root approves the following Sol-authored final test-only brief. Issue #409 CI correction was independently reviewed and integrated separately; it changes no PCM source. + +## Attempt 3 authorization — final test-only correction + +## Authority and boundary + +Attempt 2 at clean pushed `c3c2c972` failed dedicated Astra review only because three required regressions remain ineffective or incomplete. The product correction, strict real-DOM consumer, normal package gate, headless gate, copied prelude, cleanup implementation, provenance, and six generated artifact bytes independently pass. Preserve them unchanged. + +This is the third and final attempt under the issue workflow. Luna may edit only: + +- `sdk/test/browser-pcm-evals.mjs` — focused test corrections below. +- `.github/ISSUE_SPECS/405-move-the-existing-browser-pcm-feed-into-the-typescript-sdk.md` — truthful attempt-3 evidence after gates. + +No production, package, adapter, Rust, generated artifact, ABI, workflow, dependency, runner, or CI-#409 edit is authorized. Add no framework, broad matrix, new feature, or fourth attempt. + +## Exact remaining blockers + +1. **Allocation coverage over the complete populated-drain scenario.** Keep tracking armed only around `process()` execution, but assert the tracker remains empty after all first, later successful, result-6 retry, partial, seek, stale-drop, and underrun processing. Preserve the unconditional inserted-allocation red discriminator. Add direct assertions for captured submit generation/start/channels/frames/end and successful retry, captured seek generation/frame and retry, and that the stale queued slot is never submitted. Astra's exact escaping mutant inserts `if (control[CONTROL_WROTE] > 1) new Float32Array(4)` before `const staging = this.sourcePcm`; the focused suite must fail it. + +2. **Full-ring byte retention and already-frozen ring boundaries.** Immediately before a capacity-two full `reserve()`, snapshot queued slot headers, queued planar PCM, and write index. After `reserve()` returns `null`, assert all snapshots are unchanged and overflow advances exactly once. Astra's exact escaping mutant zeroes the first queued PCM plane in the full-ring null branch; the suite must fail it. In the same fixture complete only the original small boundary assertions: literal layout constants and every initialized control word including the last four; decoded bytes of a 128-byte multibyte ID; signed generation tag distinct from full generation; malformed writer-buffer magic plus zero/non-power-of-two shape; and explicit index wrap/occupancy. Use discriminating generation values, not 1/7/8. Do not add fuzzing or a reader implementation. + +3. **Observable cleanup ordering and deterministic lifecycle ownership.** In attach-post failure, capture all three engaged writer states inside `disconnect()` and require `[0,0,0]`; assert exactly one detach and one disconnect while both throw, and preserve `PcmFeedError("attachPost")`. Astra's exact escaping mutant moves ring release after disconnect; the suite must fail it. Add the missing typed `nodeCreate` failure. Replace the weak timeout case with an engaged ring, one explicitly controlled deadline-owner wait, and a second never-resolving wait; advance and resolve only the owner, then assert ordered results `readyTimeout`, `closed`, one release/detach/disconnect, and idempotent close. Bound both this observation and the existing two-blocked-caller close case with the test harness so a regression fails promptly. + +Use `/private/tmp/dx-405-astra-mutations-attempt2.mjs` and `/private/tmp/dx-405-astra-review-attempt2.md` as exact reviewer reproductions; do not copy them into the repository. + +## Handoff and gates + +Implement one coherent test tranche. Run the focused PCM eval, then `bash scripts/check-sdk-headless.sh /private/tmp/dx-393-current-artifacts`, and the existing packed/package gate from `sdk` against that same artifact directory. Also run `git diff --check` and exact-path audit. Record subtest names/counts and prove each of the three reviewer mutants turns red. Do not alter production to satisfy a test. + +Root must checkpoint the exact allowed paths before any further Luna work and push the review commit. Dedicated Astra then reviews that exact commit for the three blockers and scope conservation. On PASS, root synchronizes issue/PR evidence; on FAIL, stop and rescope under the three-attempt rule. Adapter migration remains paused until PASS. + +## Luna attempt 3 — final focused-test correction (2026-09-05) + +Luna made the final authorized test-only correction in `sdk/test/browser-pcm-evals.mjs`. The ring fixture now freezes the literal layout/control words through the final control words, decodes the exact 128-byte multibyte ID boundary, distinguishes signed generation tags from full generations, proves malformed magic and zero/non-power-of-two shapes, snapshots queued headers/planar PCM/write index across full-ring backpressure, and exercises index wrap/occupancy. The lifecycle fixture observes all engaged writer states inside disconnect, counts one detach/disconnect under throwing cleanup, covers typed `nodeCreate`, and bounds both blocked-ready and deterministic timeout ownership with ordered `readyTimeout`/`closed` results. The prelude fixture keeps allocation tracking armed across first, later successful, backpressure retry, partial, seek retry, stale-drop, and underrun processing, and captures submit/seek metadata while proving the stale slot is never submitted. + +Final evidence: + +- `node --test sdk/test/browser-pcm-evals.mjs`: 4 pass / 0 fail; log `/private/tmp/dx-405-luna-attempt3-focused.log`. +- Exact Astra reproductions from `/private/tmp/dx-405-astra-mutations-attempt2.mjs`: all three exit 1 as intended — steady populated-drain allocation, full-ring PCM corruption, and release-after-disconnect; summary `/private/tmp/dx-405-luna-attempt3-mutants.log`, isolated logs under `/private/tmp/dx-405-mutations-g5osC2/`. +- `bash scripts/check-sdk-headless.sh /private/tmp/dx-393-current-artifacts`: 137 pass / 1 platform skip / 0 fail; log `/private/tmp/dx-405-luna-attempt3-headless.log`. +- From `sdk`, `npm run check:package -- /private/tmp/dx-393-current-artifacts`: PASS, including the fresh packed consumer and package artifact checks; log `/private/tmp/dx-405-luna-attempt3-package.log`. +- `git diff --check`: PASS. Exact working-tree diff contains only `sdk/test/browser-pcm-evals.mjs` and this issue evidence file; log `/private/tmp/dx-405-luna-attempt3-diff.log`. + +No production, package, adapter, generated artifact, ABI, workflow, dependency, runner, or CI-#409 paths changed. No Rust rebuild or Darwin baseline repin was performed. This is the third and final attempt; root must checkpoint and push these two exact paths for dedicated Astra review. No PASS or issue closure is claimed by Luna. + +## Dedicated Astra attempt 3 verdict — FAIL; implementation STOPPED (2026-09-05) + +Astra reviewed `2473cfdf`. All three prior escaping mutants now fail at their intended assertions, and focused tests and packed consumer pass. Two required discriminators still escape: a retained-slot retry may skip the actual submission, and the writer may accept malformed non-power-of-two capacity. These are test-evidence gaps; the reviewed production corrections remain unchanged. The final review is attached to PR #398. The three-attempt budget is consumed: no fourth implementation/test revision is authorized here. Claims requiring these regressions remain unqualified. + +For delivery isolation, approved host request-ID and CI-fixture work is now on PR #413. This stopped PCM branch merges that reviewed base without changing its PCM implementation and is preserved as a separate dependent PR. This administrative isolation is not a new implementation attempt. No merge, publication or PCM qualification PASS is claimed. + +## Renewed execution — user-confirmed end-to-end plan + +After the historical attempt stop, the user explicitly confirmed the SDK/adapter ownership plan, requested execution of the rest with Astra medium, and set completion as the real misofm/app flow without shortcuts. This authorizes renewed completion of this existing boundary, preserving prior evidence and all functional/realtime requirements. Astra medium will correct the outstanding concrete retry-submission and malformed-writer-capacity test discriminators, run the existing focused/package gates, and submit a complete result to a separate Astra medium reviewer. No product feature, backend, protocol, or test framework is added. This is renewed user-directed work, not a claim that the earlier attempt passed. + +## Astra renewed completion checkpoint (2026-09-05) + +The renewed test-only correction closes the two concrete evidence gaps without changing production: the prelude fixture now requires four captured calls after refusal and five after retry, then checks the new call's exact generation, start frame, channels, frame count, end flag and PCM. The writer fixture supplies a capacity-four buffer whose capacity word is changed to three and requires constructor rejection. This distinguishes the writer boundary from the existing creator validation. + +Audit of the current ring, feed lifecycle, copied prelude, public exports and packed consumers found no additional product correction needed for the frozen SDK boundary. The existing evidence still covers literal layout/generation fields, full-ring retention, mono/stereo partial PCM, seek retry and stale-drop behavior, terminal readiness and cleanup ordering, allocation tracking over populated drains, real DOM assignability and installed-package consumption. App/adapter integration remains downstream and is not claimed by this checkpoint. + +Validation of the coherent tranche: + +- Focused PCM eval: all four named cases pass — `MSB1 layout and writer preserve frozen bytes, headers, counters and reuse`; `feed lifecycle preserves URL, typed seams, identity and terminal cleanup`; `attach-post cleanup releases engaged rings before throwing cleanup failures`; `moved prelude drains odd mono/stereo rings and allocation mutation turns red`. Log: `/private/tmp/dx-405-renewed-focused.log`. +- All five exact reviewer mutants fail meaningfully. The three earlier reproductions from `/private/tmp/dx-405-astra-mutations-attempt2.mjs` fail at the complete allocation tracker, queued PCM snapshot and disconnect-time writer states respectively (logs `/private/tmp/dx-405-mutations-0X7KfD/`, summary `/private/tmp/dx-405-renewed-mutants.log`). The unchanged reviewer retry-drop worklet fails the new call-count assertion with `4 !== 5`; the unchanged non-power-of-two writer mutant fails with a missing constructor exception. Updated test copies reuse the reviewer sources under `/private/tmp/dx-405-final-boundaries-1DjM6m/renewed-{retry,writer}.mjs`; logs `/private/tmp/dx-405-renewed-{retry,writer}.log`. +- Type and generated gates pass: `/private/tmp/dx-405-renewed-types.log`, `/private/tmp/dx-405-renewed-generated.log`. +- Existing headless wrapper against `/private/tmp/dx-393-current-artifacts`: 137 pass, 1 platform skip, 0 fail; `/private/tmp/dx-405-renewed-headless.log`. +- Existing package gate against the same artifact directory passes, including fresh packed strict DOM and runtime consumers; `/private/tmp/dx-405-renewed-package.log`. +- All six staged generated artifacts are byte-identical to that reviewed baseline. Prelude hash remains `d81c2159b6ca088df97e76f09c4803540e6451920cc3af1b091336fa1bdba58d`; staged NOTICE is byte-identical and its three provenance identities pass the packed gate. `/private/tmp/dx-405-renewed-artifacts.log` records the comparison. No Rust rebuild or Darwin repin. +- `git diff --check` passes; only this spec and `sdk/test/browser-pcm-evals.mjs` changed. + +Implementation is paused at this green exact-path checkpoint for root commit/push and separate Astra review. Historical FAIL verdicts stand; this record makes no independent-review PASS, remote closure, publication or app end-to-end claim. + +## Renewed independent Astra medium review — PASS + +Dedicated reviewer independently passed `783b4266`. All five known escaping mutations now fail at their intended assertions, including actual retry-submit count and malformed supplied writer capacity. Focused tests and fresh strict-DOM/runtime packed consumer pass. The six generated artifacts remain identical and no production/prelude rewrite was required. This SDK boundary is complete and reviewed; downstream app integration is still required for the overall goal. Prior failed attempts remain historical evidence. Full review attached to PR#398. diff --git a/NOTICE b/NOTICE index 7f6e377ff..2c7ddbf9b 100644 --- a/NOTICE +++ b/NOTICE @@ -1,3 +1,13 @@ Miso Engine This product includes software developed by the Miso Engine contributors. + +The browser PCM feed ring and attachment sources are adapted from +misofm/engine-web-adapter at commit 63b4ee6212287000ff85e1cfa969d385f6246d2d. +The MSB1 writer source was adapted from misofm/engine at commit +bd7f330a9773ce43bb077f0e6d5c8fc30fe9e27c. +The MSB1 allocation/reader contract and AudioWorklet feed prelude/attach +mechanism were adapted from misofm/app at commit +7485693e9bbcf2f65a91a4e5950e22d678d99062. +The TypeScript ring/feed move and packaged asset are retained under the +engine-web-adapter lineage above; this notice applies to both sources. diff --git a/sdk/codegen/stage-package.mjs b/sdk/codegen/stage-package.mjs index e75de4c88..071565582 100755 --- a/sdk/codegen/stage-package.mjs +++ b/sdk/codegen/stage-package.mjs @@ -61,4 +61,9 @@ await copyFile( resolve(sdkRoot, "dist", "browser", "shipped-host.d.ts"), ); await copyFile(resolve(repoRoot, "LICENSE"), resolve(sdkRoot, "dist", "LICENSE")); +await copyFile( + resolve(sdkRoot, "src", "browser-assets", "miso-engine-v1-pcm-feed-worklet.js"), + resolve(destination, "miso-engine-v1-pcm-feed-worklet.js"), +); +await copyFile(resolve(repoRoot, "NOTICE"), resolve(sdkRoot, "dist", "NOTICE")); console.log(`staged ${expected.length} Engine V1 artifacts and package manifest`); diff --git a/sdk/src/assets.ts b/sdk/src/assets.ts index 2b1f7d79f..427b688f9 100644 --- a/sdk/src/assets.ts +++ b/sdk/src/assets.ts @@ -28,6 +28,7 @@ export const BUNDLED_ENGINE_ASSETS = Object.freeze({ parameterMetadata: new URL("./assets/miso-engine-v1-parameter-metadata.json", import.meta.url), abiLayout: new URL("./assets/miso-engine-v1-abi-layout.json", import.meta.url), manifest: new URL("./assets/miso-engine-v1-sdk-manifest.json", import.meta.url), + pcmFeedWorklet: new URL("./assets/miso-engine-v1-pcm-feed-worklet.js", import.meta.url), }); export interface PackageAssetRecord { 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 new file mode 100644 index 000000000..0bce102f1 --- /dev/null +++ b/sdk/src/browser-assets/miso-engine-v1-pcm-feed-worklet.js @@ -0,0 +1,523 @@ +/* Adapted verbatim from misofm/app 7485693e9bbcf2f65a91a4e5950e22d678d99062; see NOTICE. */ +/** + * The B3 shared-memory feed, worklet side — app-owned, not vendored. + * + * This module is added to the `AudioWorkletGlobalScope` *before* the engine's + * own worklet module. It does two things and nothing else: + * + * 1. wraps the engine processor at the moment it registers itself, adding a + * `SharedArrayBuffer` drain at the top of `process()`; + * 2. registers a second, tiny processor whose only job is to own a port the + * engine's strict message schema never sees, so the rings can be handed in + * without ever posting an unknown tag at the engine. + * + * ## Why a `registerProcessor` wrapper + * + * `public/wasm/engine-v1/miso-engine-v1-audio-worklet.js` is pinned engine + * bytes and must not be edited, and it exports nothing — it declares a class + * and calls `registerProcessor` at top level. A worklet module cannot import a + * binding out of it. What it *can* do is share the global scope: every module + * added to one `AudioWorklet` runs in the same `AudioWorkletGlobalScope`, so a + * module that runs first can replace `registerProcessor` and receive the class + * as an argument when the engine registers it. The engine's own bytes are + * fetched, parsed and evaluated exactly as they are today; the only difference + * is which function they hand their class to. + * + * Registration is total: whatever happens while wrapping, exactly one class is + * registered under the engine's name, and if wrapping fails it is the engine's + * own unmodified class. A broken prelude therefore degrades to the shipping + * `postMessage` feed rather than to a context with no processor in it. + * + * ## Why the attach node, and why the rings arrive by port + * + * The engine processor's `receive()` turns any message it does not recognise + * into a sticky `RESULT_INVALID_ARGUMENT` — a silent, permanent failure. So the + * rings are not posted to it. They ride a separate node registered here, and + * `new AudioWorkletNode(context, "miso-sab-feed-attach")` throwing is the main + * thread's proof that this module is loaded and wrapping: the feature + * detection is a constructor call, not a hopeful message. The caller + * constructs it with no inputs and one output — Web Audio refuses a node with + * neither — and never connects it, because construction alone is what builds + * the processor that owns the port. + * + * They arrive on that node's **port**, and only on its port. Real Chromium + * silently drops a `SharedArrayBuffer` passed through + * `AudioWorkletNodeOptions.processorOptions`: the node constructs, the + * processor is built, and the buffer is simply not in the options object it + * receives. There is no error to catch on either side. A port message carries + * it correctly, so there is exactly one route in and no second one to be + * tempted by. + * + * ## What the drain does per block + * + * For each attached ring, once, at the top of `process()`, with no allocation + * on the steady path and no `Atomics.wait` ever: + * + * - apply a pending seek (a generation and a frame published through the + * ring, so a seek can never overtake or trail the planes around it); + * - drain published chunks, dropping any whose generation tag is stale; + * - copy each chunk's planes into the engine's existing PCM staging buffer + * and call the existing `miso_engine_web_v1_source_submit` export — the + * same copy, the same call and the same arguments the `postMessage` path + * makes, which is why the two render bit-identically; + * - stop, leaving the chunk in the ring, when the engine answers + * `RESULT_BACKPRESSURE`; + * - maintain the engine ring's depth and count a block that found it empty + * as an underrun. + * + * The engine never sees the shared buffer and never runs an atomic. Every + * index, generation and counter in this file is JS, and its layout is pinned + * against `src/lib/mixer/engine/sab-ring.ts` by `sab-ring-layout.test.ts`. + */ + +const ENGINE_PROCESSOR_NAME = "miso-engine-v1-audio-worklet" +const ATTACH_PROCESSOR_NAME = "miso-sab-feed-attach" + +const SAB_WRAP = 1 << 30 +const SAB_WRAP_MASK = SAB_WRAP - 1 +const SAB_MAGIC = 0x4d534231 +const SAB_VERSION = 1 + +const CONTROL_MAGIC = 0 +const CONTROL_VERSION = 1 +const CONTROL_CAPACITY = 2 +const CONTROL_CHANNELS = 3 +const CONTROL_FRAME_CAPACITY = 4 +const CONTROL_HEADER_OFFSET = 5 +const CONTROL_PCM_OFFSET = 6 +const CONTROL_ID_LENGTH = 7 +const CONTROL_WRITE_INDEX = 8 +const CONTROL_READ_INDEX = 9 +const CONTROL_GENERATION_TAG = 10 +const CONTROL_SEEK_EPOCH = 11 +const CONTROL_WRITER_STATE = 12 +const CONTROL_ATTACHED = 13 +const CONTROL_WROTE = 14 +const CONTROL_OVERFLOW = 15 +const CONTROL_SUBMITTED = 16 +const CONTROL_STALE = 17 +const CONTROL_REFUSED = 18 +const CONTROL_LAST_RESULT = 19 +const CONTROL_UNDERRUNS = 20 +const CONTROL_DRAIN_BLOCKS = 21 +const CONTROL_SEEKS_APPLIED = 22 +const CONTROL_DEPTH = 23 +const CONTROL_TORN = 24 +const CONTROL_FINISHED = 25 +const CONTROL_ERRORS = 26 +const CONTROL_SUBMITTED_GENERATION_TAG = 27 + +const CONTROL_BYTES = 128 +const CONTROL_I64_OFFSET = 112 +const CONTROL_I64_SEEK_GENERATION = 0 +const CONTROL_I64_SEEK_FRAME = 1 + +const ID_OFFSET = 128 + +const SLOT_HEADER_BYTES = 32 +const SLOT_SEQUENCE = 0 +const SLOT_GENERATION_TAG = 1 +const SLOT_FRAMES = 2 +const SLOT_FLAGS = 3 +// Two and three: the i64 pair sits after the four i32 words, and both views +// address the same 32 bytes. +const SLOT_I64_GENERATION = 2 +const SLOT_I64_START_FRAME = 3 +const FLAG_END_OF_REGION = 1 + +const RESULT_OK = 0 +const RESULT_BACKPRESSURE = 6 + +/** 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 + * order, but the queue costs one field and removes the ordering assumption. */ +const registry = { engine: null, pending: null } + +function deliver(rings) { + if (registry.engine === null) { + registry.pending = rings + return + } + registry.engine.attachSharedRings(rings) +} + +function withdraw(rings) { + if (registry.engine === null) { + registry.pending = null + return + } + registry.engine.detachSharedRings(rings) +} + +/** One bound ring: every view cut once, so the drain allocates nothing. */ +function bindRing(shared, quantumFrames, maximumChannels) { + if ( + typeof SharedArrayBuffer === "undefined" || + !(shared instanceof SharedArrayBuffer) + ) { + return null + } + if (shared.byteLength < CONTROL_BYTES) return null + const control = new Int32Array(shared, 0, CONTROL_BYTES / 4) + if ( + Atomics.load(control, CONTROL_MAGIC) !== SAB_MAGIC || + control[CONTROL_VERSION] !== SAB_VERSION + ) { + return null + } + const capacity = control[CONTROL_CAPACITY] + const channels = control[CONTROL_CHANNELS] + const frameCapacity = control[CONTROL_FRAME_CAPACITY] + const headerOffset = control[CONTROL_HEADER_OFFSET] + const pcmOffset = control[CONTROL_PCM_OFFSET] + const idLength = control[CONTROL_ID_LENGTH] + // A ring the engine could not accept a chunk from is worse than no ring: it + // would refuse every block forever. Refuse it here instead, once. + if ( + capacity <= 0 || + (capacity & (capacity - 1)) !== 0 || + channels <= 0 || + channels > maximumChannels || + frameCapacity !== quantumFrames || + idLength <= 0 || + headerOffset + capacity * SLOT_HEADER_BYTES !== pcmOffset || + pcmOffset + capacity * channels * frameCapacity * 4 > shared.byteLength + ) { + return null + } + const planes = [] + for (let slot = 0; slot < capacity; slot += 1) { + const slotPlanes = [] + for (let channel = 0; channel < channels; channel += 1) { + slotPlanes.push( + new Float32Array( + shared, + pcmOffset + (slot * channels + channel) * frameCapacity * 4, + frameCapacity + ) + ) + } + planes.push(slotPlanes) + } + return { + shared, + control, + controlI64: new BigInt64Array(shared, CONTROL_I64_OFFSET, 2), + headers: new Int32Array( + shared, + headerOffset, + (capacity * SLOT_HEADER_BYTES) / 4 + ), + headersI64: new BigInt64Array( + shared, + headerOffset, + (capacity * SLOT_HEADER_BYTES) / 8 + ), + idBytes: new Uint8Array(shared, ID_OFFSET, idLength), + idLength, + planes, + capacity, + channels, + frameCapacity, + /** The seek epoch this ring has already applied. */ + seenEpoch: Atomics.load(control, CONTROL_SEEK_EPOCH), + /** Chunks the engine holds for this source but has not yet rendered. */ + depth: 0, + finished: false, + /** The staging-buffer id view, cut against the Wasm memory it was cut + * from; a memory that grew invalidates it and the drain re-cuts it. */ + idTarget: null, + idTargetBuffer: null, + } +} + +/** + * Add the drain to the engine processor. + * + * Only three methods exist here, and none of them changes anything the engine + * does with a chunk: `process` runs the drain and then calls the engine's own + * `process`, unmodified, for the same block. + */ +function wrapEngineProcessor(Base) { + return class MisoSabFeedProcessor extends Base { + constructor(options) { + super(options) + this.sabRings = [] + registry.engine = this + if (registry.pending !== null) { + const pending = registry.pending + registry.pending = null + this.attachSharedRings(pending) + } + } + + /** + * Bind a set of rings. Called off the audio path, from an attach node's + * construction. + * + * Additive and idempotent: a buffer already bound is rebound rather than + * bound twice, and rings from an earlier attach are left alone. One + * session per context is the only shape this app builds, but "the second + * session silently unbinds the first" is not a failure worth leaving + * available for the cost of a filter. + */ + attachSharedRings(rings) { + if (!Array.isArray(rings)) return + const bound = [] + const incoming = [] + for (let index = 0; index < rings.length; index += 1) { + const ring = bindRing( + rings[index], + this.quantumFrames, + this.maximumSourceChannels + ) + if (ring === null) continue + incoming.push(ring.shared) + bound.push(ring) + } + const kept = this.sabRings.filter( + (ring) => incoming.indexOf(ring.shared) < 0 + ) + for (let index = 0; index < bound.length; index += 1) { + // Cut the first-drain id view here, on the attach/control path. The + // steady process path must never allocate a typed-array view. + bound[index].idTarget = new Uint8Array( + this.memoryBuffer, + this.sourceIdPointer, + Math.min(bound[index].idLength, this.sourceIdCapacity) + ) + bound[index].idTargetBuffer = this.memoryBuffer + Atomics.store(bound[index].control, CONTROL_ATTACHED, 1) + } + this.sabRings = kept.concat(bound) + } + + /** Unbind exactly the rings an attach node brought, and nothing else. */ + detachSharedRings(rings) { + if (!Array.isArray(rings)) return + this.sabRings = this.sabRings.filter((ring) => { + if (rings.indexOf(ring.shared) < 0) return true + Atomics.store(ring.control, CONTROL_ATTACHED, 0) + return false + }) + } + + process(inputs, outputs) { + const rings = this.sabRings + if ( + rings !== undefined && + rings.length !== 0 && + this.ready === true && + this.disposed !== true && + this.stickyResult === RESULT_OK && + this.exports.memory.buffer === this.memoryBuffer + ) { + for (let index = 0; index < rings.length; index += 1) { + const ring = rings[index] + try { + this.drainSharedRing(ring) + } catch (_) { + // A feed that throws must not take the render with it: the block + // still renders from whatever the engine already holds, and the + // counter says the feed is the reason it may run dry. + ring.control[CONTROL_ERRORS] += 1 + } + } + } + return super.process(inputs, outputs) + } + + /** One ring, one block. Reads are `Atomics.load` on the writer's words and + * plain loads on its own; the single `Atomics.store` of the read index at + * the end is what releases the slots back to the writer. */ + drainSharedRing(ring) { + const control = ring.control + // Memory growth after attach invalidates the pre-cut view. Refuse this + // drain rather than allocating a replacement inside process(). + 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 + } + + const generationTag = Atomics.load(control, CONTROL_GENERATION_TAG) + const write = Atomics.load(control, CONTROL_WRITE_INDEX) + const capacityMask = ring.capacity - 1 + const staging = this.sourcePcm + const quantumFrames = this.quantumFrames + let read = control[CONTROL_READ_INDEX] + + while (read !== write) { + 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 + break + } + if (ring.headers[word + SLOT_GENERATION_TAG] !== generationTag) { + read = (read + 1) & SAB_WRAP_MASK + control[CONTROL_STALE] += 1 + continue + } + const frames = ring.headers[word + SLOT_FRAMES] + const flags = ring.headers[word + SLOT_FLAGS] + if (frames <= 0 || frames > ring.frameCapacity) { + read = (read + 1) & SAB_WRAP_MASK + control[CONTROL_ERRORS] += 1 + continue + } + const slotPlanes = ring.planes[slot] + for (let channel = 0; channel < ring.channels; channel += 1) { + const plane = slotPlanes[channel] + // A full quantum is the steady case and copies the view as it is; + // only a region's last chunk is short, and only it cuts a subview. + // Every slot plane is pre-zeroed by the writer. Copy its full fixed + // quantum even for the legal tail, avoiding a tail subview allocation. + staging.set(plane, channel * quantumFrames) + } + ring.idTarget.set(ring.idBytes) + const word64 = slot * (SLOT_HEADER_BYTES / 8) + const result = this.exports.miso_engine_web_v1_source_submit( + this.handle, + ring.idLength, + ring.headersI64[word64 + SLOT_I64_GENERATION], + ring.headersI64[word64 + SLOT_I64_START_FRAME], + ring.channels, + frames, + (flags & FLAG_END_OF_REGION) !== 0 ? 1 : 0 + ) + if (result === RESULT_BACKPRESSURE) { + // The engine's ring is full. Leave the chunk where it is — this is + // the backpressure that keeps the feeder from running ahead. + break + } + read = (read + 1) & SAB_WRAP_MASK + if (result === RESULT_OK) { + // Publish the generation only after the engine accepted its PCM. + // The main-thread start gate compares this with GENERATION_TAG, so + // lifetime-cumulative SUBMITTED can never validate a later seek. + Atomics.store( + control, + CONTROL_SUBMITTED_GENERATION_TAG, + ring.headers[word + SLOT_GENERATION_TAG] + ) + control[CONTROL_SUBMITTED] += 1 + ring.depth += 1 + if ((flags & FLAG_END_OF_REGION) !== 0) { + ring.finished = true + control[CONTROL_FINISHED] = 1 + } + } else { + control[CONTROL_REFUSED] += 1 + control[CONTROL_LAST_RESULT] = result + } + } + + Atomics.store(control, CONTROL_READ_INDEX, read) + control[CONTROL_DRAIN_BLOCKS] += 1 + // The block about to be rendered consumes one quantum of this source. + // Reaching zero with a live feeder and audio still to come is exactly + // what an underrun is, and it is counted here because it is the only + // place that knows it. + if (ring.depth > 0) { + ring.depth -= 1 + } else if ( + !ring.finished && + Atomics.load(control, CONTROL_WRITER_STATE) === 1 + ) { + control[CONTROL_UNDERRUNS] += 1 + } + control[CONTROL_DEPTH] = ring.depth + } + } +} + +/** The rings' way in. Owns a port the engine's schema never reads, so nothing + * here can put the engine into a sticky state. */ +class MisoSabFeedAttachProcessor extends AudioWorkletProcessor { + constructor() { + super() + // What this node brought, so a detach can withdraw exactly that and not + // another session's rings. It starts empty: nothing is delivered at + // construction, because `processorOptions` cannot carry a ring. + this.delivered = [] + this.port.onmessage = (event) => { + const data = event.data + if (data === null || typeof data !== "object") return + if (data.op === "attach" && Array.isArray(data.rings)) { + this.delivered = data.rings + deliver(data.rings) + } else if (data.op === "detach") { + withdraw(this.delivered) + this.delivered = [] + } + } + } + + process() { + return true + } +} + +const originalRegister = globalThis.registerProcessor +if (typeof originalRegister !== "function") { + throw new TypeError("miso-sab-feed: no registerProcessor in this scope") +} + +function interceptingRegister(name, constructor) { + if (name !== ENGINE_PROCESSOR_NAME) { + originalRegister.call(globalThis, name, constructor) + return + } + globalThis.registerProcessor = originalRegister + let Registered = constructor + try { + Registered = wrapEngineProcessor(constructor) + } catch (_) { + // Wrapping failed. The engine's own class is registered unchanged and the + // attach node is simply never told about any rings, which is the shipping + // `postMessage` feed — not a context with nothing registered in it. + Registered = constructor + } + originalRegister.call(globalThis, name, Registered) +} + +globalThis.registerProcessor = interceptingRegister +// A scope that will not take the patch must fail here, while the main thread +// is still awaiting `addModule` and can still choose the message path — never +// later, when the engine has already registered itself unwrapped. +if (globalThis.registerProcessor !== interceptingRegister) { + throw new TypeError("miso-sab-feed: registerProcessor is not patchable") +} + +originalRegister.call( + globalThis, + ATTACH_PROCESSOR_NAME, + MisoSabFeedAttachProcessor +) diff --git a/sdk/src/browser/index.ts b/sdk/src/browser/index.ts index 67c23e843..fc51757d2 100644 --- a/sdk/src/browser/index.ts +++ b/sdk/src/browser/index.ts @@ -13,3 +13,5 @@ export * from "./policy.ts"; // worklet host directly instead of going through `createEngine`, which is a browser consumer, so // it is barrel surface rather than a deep import. export * from "./host-mirror.ts"; +export * from "./pcm-ring.ts"; +export * from "./pcm-feed.ts"; diff --git a/sdk/src/browser/pcm-feed.ts b/sdk/src/browser/pcm-feed.ts new file mode 100644 index 000000000..9d375bbf9 --- /dev/null +++ b/sdk/src/browser/pcm-feed.ts @@ -0,0 +1,91 @@ +import { BUNDLED_ENGINE_ASSETS } from "../assets.ts"; +import { MisoUsageError } from "../core/errors.ts"; +import { MSB1_CONTROL, MSB1_CONTROL_BYTES, createMsb1Ring } from "./pcm-ring.ts"; + +export type PcmFeedOperation = "moduleLoad" | "nodeCreate" | "attachPost" | "readyTimeout" | "closed"; + +export class PcmFeedError extends Error { + readonly operation: PcmFeedOperation; + constructor(operation: PcmFeedOperation, message: string, cause?: unknown) { + super(message, { cause }); this.name = "PcmFeedError"; this.operation = operation; + } +} + +export interface FeedPort { postMessage(message: unknown): void } +export interface FeedNode { readonly port: FeedPort; disconnect(): void } +export interface FeedContext { readonly audioWorklet: { addModule(url: string): Promise } } +export interface FeedNodeOptions { readonly numberOfInputs: number; readonly numberOfOutputs: number } +export interface FeedSource { readonly sourceId: string; readonly channels: 1 | 2 } +export interface FeedOptions { + readonly context: Context; + readonly sources: readonly FeedSource[]; + readonly quantumFrames: number; + readonly capacityChunks?: number; + readonly moduleUrl?: string | URL; + readonly createNode?: (context: Context, name: string, options: FeedNodeOptions) => FeedNode; +} +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; + close(): void; +} + +export async function prepareEngineFeed(context: FeedContext, moduleUrl: string | URL = BUNDLED_ENGINE_ASSETS.pcmFeedWorklet): Promise { + try { await context.audioWorklet.addModule(String(moduleUrl)); } + catch (error) { throw new PcmFeedError("moduleLoad", "PCM feed worklet prelude could not load", error); } +} + +export function attachEngineFeed(options: FeedOptions): EngineFeed { + if (!Number.isSafeInteger(options.quantumFrames) || options.quantumFrames <= 0) throw new MisoUsageError("quantumFrames must be a positive integer"); + const rings = options.sources.map((source) => createMsb1Ring({ sourceId: source.sourceId, channels: source.channels, frameCapacity: options.quantumFrames, capacity: options.capacityChunks ?? 64 })); + let node: FeedNode; + try { + node = options.createNode?.(options.context, "miso-sab-feed-attach", { numberOfInputs: 0, numberOfOutputs: 1 }) ?? defaultNode(options.context); + } catch (error) { + release(rings); throw new PcmFeedError("nodeCreate", "Engine feed attach processor is unavailable", error); + } + let state: "pending" | "active" | "closed" = rings.length === 0 ? "active" : "pending"; + let signalTerminal!: () => void; + const terminal = new Promise((resolve) => { signalTerminal = resolve; }); + let terminalSignaled = false; + const close = (): void => { + if (state === "closed") return; + state = "closed"; release(rings); + if (!terminalSignaled) { terminalSignaled = true; signalTerminal(); } + try { node.port.postMessage({ op: "detach" }); } catch { /* context already closed */ } + try { node.disconnect(); } catch { /* never connected */ } + }; + try { node.port.postMessage({ op: "attach", rings }); } + catch (error) { close(); throw new PcmFeedError("attachPost", "Engine feed attach message could not be posted", error); } + return { + rings, + get state() { return state; }, + async ready(settings = {}): Promise { + if (state === "closed") throw new PcmFeedError("closed", "Engine feed is closed"); + if (state === "active") return; + const now = settings.now ?? (() => performance.now()); + const wait = settings.wait ?? ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + const deadline = now() + (settings.timeoutMs ?? 2_000); + while (state === "pending") { + if (rings.every(attached)) { state = "active"; return; } + if (now() >= deadline) { close(); throw new PcmFeedError("readyTimeout", "Engine feed attach confirmation timed out"); } + await Promise.race([wait(0), terminal]); + } + if (state === "closed") throw new PcmFeedError("closed", "Engine feed is closed"); + }, + close, + }; +} + +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); +} +function attached(ring: SharedArrayBuffer): boolean { + return Atomics.load(new Int32Array(ring, 0, MSB1_CONTROL_BYTES / 4), MSB1_CONTROL.ATTACHED) === 1; +} +function defaultNode(context: FeedContext): FeedNode { + const Constructor = (globalThis as unknown as { AudioWorkletNode?: new (context: unknown, name: string, options: FeedNodeOptions) => FeedNode }).AudioWorkletNode; + if (Constructor === undefined) throw new Error("AudioWorkletNode is unavailable"); + return new Constructor(context, "miso-sab-feed-attach", { numberOfInputs: 0, numberOfOutputs: 1 }); +} diff --git a/sdk/src/browser/pcm-ring.ts b/sdk/src/browser/pcm-ring.ts new file mode 100644 index 000000000..1b907b1bf --- /dev/null +++ b/sdk/src/browser/pcm-ring.ts @@ -0,0 +1,36 @@ +/** MSB1 shared PCM ring authority, adapted from engine-web-adapter@63b4ee6. */ +export const MSB1_MAGIC = 0x4d534231; +export const MSB1_VERSION = 1; +export const MSB1_WRAP = 1 << 30; +export const MSB1_CONTROL_BYTES = 128; +export const MSB1_CONTROL_I64_OFFSET = 112; +export const MSB1_ID_OFFSET = 128; +export const MSB1_ID_CAPACITY = 128; +export const MSB1_HEADER_OFFSET = 256; +export const MSB1_SLOT_HEADER_BYTES = 32; +export const MSB1_FLAG_END_OF_REGION = 1; +export const MSB1_CONTROL = Object.freeze({ MAGIC: 0, VERSION: 1, CAPACITY: 2, CHANNELS: 3, FRAME_CAPACITY: 4, HEADER_OFFSET: 5, PCM_OFFSET: 6, ID_LENGTH: 7, WRITE_INDEX: 8, READ_INDEX: 9, GENERATION_TAG: 10, SEEK_EPOCH: 11, WRITER_STATE: 12, ATTACHED: 13, WROTE: 14, OVERFLOW: 15, SUBMITTED: 16, STALE: 17, REFUSED: 18, LAST_RESULT: 19, UNDERRUNS: 20, DRAIN_BLOCKS: 21, SEEKS_APPLIED: 22, DEPTH: 23, TORN: 24, FINISHED: 25, ERRORS: 26, SUBMITTED_GENERATION_TAG: 27 } as const); +const SLOT = { SEQUENCE: 0, GENERATION_TAG: 1, FRAMES: 2, FLAGS: 3 } as const; +const SLOT_I64 = { GENERATION: 2, START_FRAME: 3 } as const; +export interface Msb1RingLayout { readonly sourceId: string; readonly channels: number; readonly frameCapacity: number; readonly capacity: number } +export interface Msb1RingCounters { readonly wrote:number; readonly overflow:number; readonly submitted:number; readonly stale:number; readonly refused:number; readonly lastResult:number; readonly seeksApplied:number; readonly torn:number; readonly errors:number; readonly occupancy:number; readonly generationTag:number; readonly submittedGenerationTag:number } +export function msb1RingBytes(channels:number, frameCapacity:number, capacity:number):number { return MSB1_HEADER_OFFSET + capacity * MSB1_SLOT_HEADER_BYTES + capacity * channels * frameCapacity * 4; } +export function createMsb1Ring(layout: Msb1RingLayout): SharedArrayBuffer { + if (!power(layout.capacity)) throw new RangeError("MSB1 capacity must be a power of two"); + if (!positive(layout.channels) || !positive(layout.frameCapacity)) throw new RangeError("MSB1 shape must be positive"); + const id = new TextEncoder().encode(layout.sourceId); if (id.byteLength === 0 || id.byteLength > MSB1_ID_CAPACITY) throw new RangeError("sourceId does not fit MSB1"); + const pcm = MSB1_HEADER_OFFSET + layout.capacity * MSB1_SLOT_HEADER_BYTES; const shared = new SharedArrayBuffer(msb1RingBytes(layout.channels, layout.frameCapacity, layout.capacity)); const c = new Int32Array(shared, 0, 32); + c[MSB1_CONTROL.VERSION]=1; c[MSB1_CONTROL.CAPACITY]=layout.capacity; c[MSB1_CONTROL.CHANNELS]=layout.channels; c[MSB1_CONTROL.FRAME_CAPACITY]=layout.frameCapacity; c[MSB1_CONTROL.HEADER_OFFSET]=MSB1_HEADER_OFFSET; c[MSB1_CONTROL.PCM_OFFSET]=pcm; c[MSB1_CONTROL.ID_LENGTH]=id.byteLength; c[MSB1_CONTROL.GENERATION_TAG]=1; new Uint8Array(shared, MSB1_ID_OFFSET, id.byteLength).set(id); Atomics.store(c, MSB1_CONTROL.MAGIC, MSB1_MAGIC); return shared; +} +export class Msb1RingWriter { + readonly capacity:number; readonly channels:number; readonly frameCapacity:number; readonly #v:ReturnType; #reserved=-1; + constructor(shared:SharedArrayBuffer){this.#v=bind(shared); this.capacity=this.#v.capacity; this.channels=this.#v.channels; this.frameCapacity=this.#v.frameCapacity;} + get occupancy(){return occupancy(this.#v.control)} + engage(generation:bigint){Atomics.store(this.#v.control,MSB1_CONTROL.GENERATION_TAG,Number(BigInt.asIntN(32,generation))); Atomics.store(this.#v.control,MSB1_CONTROL.WRITER_STATE,1)} + reserve(frames:number):readonly Float32Array[]|null{if(!positive(frames)||frames>this.frameCapacity)throw new RangeError("PCM chunk does not fit MSB1"); if(this.occupancy>=this.capacity){Atomics.add(this.#v.control,MSB1_CONTROL.OVERFLOW,1);return null} const i=Atomics.load(this.#v.control,MSB1_CONTROL.WRITE_INDEX);this.#reserved=i;const p=this.#v.planes[i&(this.capacity-1)]!;for(const x of p)x.fill(0);return p} + commit(chunk:{readonly generation:bigint;readonly startFrame:bigint;readonly frames:number;readonly endOfRegion:boolean}){if(this.#reserved<0)throw new Error("MSB1 commit without reservation");const i=this.#reserved;this.#reserved=-1;const s=i&(this.capacity-1),w=s*8,w64=s*4;this.#v.headers[w+SLOT.SEQUENCE]=i;this.#v.headers[w+SLOT.GENERATION_TAG]=Number(BigInt.asIntN(32,chunk.generation));this.#v.headers[w+SLOT.FRAMES]=chunk.frames;this.#v.headers[w+SLOT.FLAGS]=chunk.endOfRegion?1:0;this.#v.headersI64[w64+SLOT_I64.GENERATION]=chunk.generation;this.#v.headersI64[w64+SLOT_I64.START_FRAME]=chunk.startFrame;Atomics.add(this.#v.control,MSB1_CONTROL.WROTE,1);Atomics.store(this.#v.control,MSB1_CONTROL.WRITE_INDEX,(i+1)&(MSB1_WRAP-1))} + seek(generation:bigint,frame:bigint){this.#v.controlI64[0]=generation;this.#v.controlI64[1]=frame;Atomics.store(this.#v.control,MSB1_CONTROL.GENERATION_TAG,Number(BigInt.asIntN(32,generation)));Atomics.add(this.#v.control,MSB1_CONTROL.SEEK_EPOCH,1)} + release(){Atomics.store(this.#v.control,MSB1_CONTROL.WRITER_STATE,0)} +} +function bind(shared:SharedArrayBuffer){if(!(shared instanceof SharedArrayBuffer))throw new TypeError("MSB1 requires SharedArrayBuffer");const control=new Int32Array(shared,0,32);if(Atomics.load(control,0)!==MSB1_MAGIC||control[1]!==1)throw new TypeError("Shared buffer is not MSB1");const capacity=control[2]!,channels=control[3]!,frameCapacity=control[4]!,headerOffset=control[5]!,pcmOffset=control[6]!;if(!power(capacity)||!positive(channels)||!positive(frameCapacity))throw new TypeError("MSB1 header is invalid");const headers=new Int32Array(shared,headerOffset,capacity*8),planes=Array.from({length:capacity},(_,s)=>Array.from({length:channels},(_,ch)=>new Float32Array(shared,pcmOffset+(s*channels+ch)*frameCapacity*4,frameCapacity)));return {control,controlI64:new BigInt64Array(shared,MSB1_CONTROL_I64_OFFSET,2),headers,headersI64:new BigInt64Array(shared,headerOffset,capacity*4),planes,capacity,channels,frameCapacity}} + function occupancy(c:Int32Array){return (Atomics.load(c,8)-Atomics.load(c,9))&(MSB1_WRAP-1)} function positive(v:number){return Number.isSafeInteger(v)&&v>0} function power(v:number){return positive(v)&&(v&(v-1))===0} diff --git a/sdk/test/browser-pcm-evals.mjs b/sdk/test/browser-pcm-evals.mjs new file mode 100644 index 000000000..9128db4d7 --- /dev/null +++ b/sdk/test/browser-pcm-evals.mjs @@ -0,0 +1,372 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +import { + MSB1_CONTROL, + MSB1_CONTROL_BYTES, + MSB1_HEADER_OFFSET, + MSB1_ID_CAPACITY, + MSB1_ID_OFFSET, + MSB1_SLOT_HEADER_BYTES, + createMsb1Ring, + msb1RingBytes, + Msb1RingWriter, +} from "../src/browser/pcm-ring.ts"; +import { attachEngineFeed, PcmFeedError, prepareEngineFeed } from "../src/browser/pcm-feed.ts"; +import { BUNDLED_ENGINE_ASSETS } from "../src/assets.ts"; + +const context = { audioWorklet: { addModule: async () => {} } }; + +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); } +function pcmSnapshot(ring, channels, frameCapacity, capacity) { + const offset = controls(ring)[MSB1_CONTROL.PCM_OFFSET]; + return Array.from({ length: capacity }, (_, slot) => Array.from({ length: channels }, (_, channel) => [ + ...new Float32Array(ring, offset + (slot * channels + channel) * frameCapacity * 4, frameCapacity), + ])); +} + +async function settleWithin(promise, milliseconds = 50) { + let timer; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("observation exceeded bound")), milliseconds); }), + ]); + } finally { + clearTimeout(timer); + } +} + +test("MSB1 layout and writer preserve frozen bytes, headers, counters and reuse", () => { + const expected = { + [1]: { bytes: 360, pcm: 320 }, + [2]: { bytes: 400, pcm: 320 }, + }; + for (const channels of [1, 2]) { + const sourceId = channels === 1 ? "mono" : "stereo"; + const ring = createMsb1Ring({ sourceId, channels, frameCapacity: 5, capacity: 2 }); + assert.equal(ring.byteLength, expected[channels].bytes); + assert.equal(msb1RingBytes(channels, 5, 2), expected[channels].bytes); + const c = controls(ring); + assert.equal(MSB1_CONTROL_BYTES, 128); + assert.equal(MSB1_ID_OFFSET, 128); + assert.equal(MSB1_ID_CAPACITY, 128); + assert.equal(MSB1_HEADER_OFFSET, 256); + assert.equal(MSB1_SLOT_HEADER_BYTES, 32); + assert.deepEqual([...c], [ + 0x4d534231, 1, 2, channels, 5, 256, expected[channels].pcm, sourceId.length, + 0, 0, 1, ...Array(21).fill(0), + ]); + assert.deepEqual([...c.slice(28)], [0, 0, 0, 0]); + assert.deepEqual([...new Uint8Array(ring, MSB1_ID_OFFSET, sourceId.length)], [...new TextEncoder().encode(sourceId)]); + + const writer = new Msb1RingWriter(ring); + const generation = 0x1_0000_0007n; + writer.engage(generation); + assert.equal(c[MSB1_CONTROL.WRITER_STATE], 1); + const planes = writer.reserve(3); + assert.equal(planes.length, channels); + assert.ok(planes.every((plane) => plane.every((value) => Object.is(value, 0)))); + planes[0].set([1, 2, 3]); + if (channels === 2) planes[1].set([4, 5, 6]); + writer.commit({ generation, startFrame: 11n, frames: 3, endOfRegion: true }); + assert.deepEqual([...headers(ring, 2).slice(0, 4)], [0, 7, 3, 1]); + assert.deepEqual([...headers64(ring, 2).slice(0, 4)], [7n << 32n, (1n << 32n) | 3n, generation, 11n]); + assert.equal(c[MSB1_CONTROL.GENERATION_TAG], 7); + assert.equal(c[MSB1_CONTROL.WRITE_INDEX], 1); + assert.equal(c[MSB1_CONTROL.WROTE], 1); + assert.equal(writer.occupancy, 1); + assert.deepEqual([...planes[0]], [1, 2, 3, 0, 0]); + const nextGeneration = 0x2_0000_0008n; + writer.seek(nextGeneration, 14n); + assert.deepEqual([...new BigInt64Array(ring, 112, 2)], [nextGeneration, 14n]); + assert.equal(c[MSB1_CONTROL.GENERATION_TAG], 8); + assert.equal(c[MSB1_CONTROL.SEEK_EPOCH], 1); + writer.release(); + assert.equal(c[MSB1_CONTROL.WRITER_STATE], 0); + } + + const exactId = "é".repeat(64); + const exactIdBytes = new TextEncoder().encode(exactId); + assert.equal(exactIdBytes.byteLength, MSB1_ID_CAPACITY); + const exactIdRing = createMsb1Ring({ sourceId: exactId, channels: 1, frameCapacity: 4, capacity: 2 }); + assert.deepEqual([...new Uint8Array(exactIdRing, MSB1_ID_OFFSET, MSB1_ID_CAPACITY)], [...exactIdBytes]); + assert.equal(new TextDecoder().decode(new Uint8Array(exactIdRing, MSB1_ID_OFFSET, MSB1_ID_CAPACITY)), exactId); + assert.throws(() => createMsb1Ring({ sourceId: `${exactId}x`, channels: 1, frameCapacity: 4, capacity: 2 }), /does not fit/); + + const ring = createMsb1Ring({ sourceId: "reuse", channels: 2, frameCapacity: 4, capacity: 2 }); + const writer = new Msb1RingWriter(ring); + writer.engage(1n); + for (let i = 0; i < 2; i += 1) { + const planes = writer.reserve(4); + planes[0].fill(i + 1); planes[1].fill(i + 2); + writer.commit({ generation: 1n, startFrame: BigInt(i * 4), frames: 4, endOfRegion: false }); + } + const before = [...headers(ring, 2)]; + const beforePcm = pcmSnapshot(ring, 2, 4, 2); + const beforeWriteIndex = controls(ring)[MSB1_CONTROL.WRITE_INDEX]; + const beforeOverflow = controls(ring)[MSB1_CONTROL.OVERFLOW]; + assert.equal(writer.reserve(4), null); + assert.equal(controls(ring)[MSB1_CONTROL.OVERFLOW], beforeOverflow + 1); + assert.deepEqual([...headers(ring, 2)], before); + assert.deepEqual(pcmSnapshot(ring, 2, 4, 2), beforePcm); + assert.equal(controls(ring)[MSB1_CONTROL.WRITE_INDEX], beforeWriteIndex); + Atomics.store(controls(ring), MSB1_CONTROL.READ_INDEX, 1); + const reused = writer.reserve(4); + assert.ok(reused); assert.ok(reused.every((plane) => plane.every((value) => Object.is(value, 0)))); + writer.commit({ generation: 1n, startFrame: 8n, frames: 4, endOfRegion: false }); + assert.equal(writer.occupancy, 2); + assert.equal(controls(ring)[MSB1_CONTROL.WRITE_INDEX], 3); + assert.equal(controls(ring)[MSB1_CONTROL.READ_INDEX], 1); + assert.deepEqual([...headers(ring, 2).slice(0, 4)], [2, 1, 4, 0]); + + assert.throws(() => new Msb1RingWriter(new ArrayBuffer(128)), /SharedArrayBuffer/); + const malformed = createMsb1Ring({ sourceId: "bad", channels: 1, frameCapacity: 4, capacity: 2 }); + new Int32Array(malformed)[MSB1_CONTROL.VERSION] = 2; + assert.throws(() => new Msb1RingWriter(malformed), /MSB1/); + const badMagic = createMsb1Ring({ sourceId: "bad", channels: 1, frameCapacity: 4, capacity: 2 }); + new Int32Array(badMagic)[MSB1_CONTROL.MAGIC] = 0; + assert.throws(() => new Msb1RingWriter(badMagic), /MSB1/); + const zeroCapacity = createMsb1Ring({ sourceId: "bad", channels: 1, frameCapacity: 4, capacity: 2 }); + new Int32Array(zeroCapacity)[MSB1_CONTROL.CAPACITY] = 0; + assert.throws(() => new Msb1RingWriter(zeroCapacity), /header is invalid/); + const nonPowerCapacity = createMsb1Ring({ sourceId: "bad", channels: 1, frameCapacity: 4, capacity: 4 }); + controls(nonPowerCapacity)[MSB1_CONTROL.CAPACITY] = 3; + assert.throws(() => new Msb1RingWriter(nonPowerCapacity), /header is invalid/); + const zeroFrameCapacity = createMsb1Ring({ sourceId: "bad", channels: 1, frameCapacity: 4, capacity: 2 }); + new Int32Array(zeroFrameCapacity)[MSB1_CONTROL.FRAME_CAPACITY] = 0; + assert.throws(() => new Msb1RingWriter(zeroFrameCapacity), /header is invalid/); + assert.throws(() => createMsb1Ring({ sourceId: "bad", channels: 1, frameCapacity: 4, capacity: 3 }), /power of two/); + + const wrapped = createMsb1Ring({ sourceId: "wrapped", channels: 1, frameCapacity: 1, capacity: 2 }); + const wrappedWriter = new Msb1RingWriter(wrapped); + const wrappedControls = controls(wrapped); + wrappedWriter.engage(1n); + Atomics.store(wrappedControls, MSB1_CONTROL.READ_INDEX, (1 << 30) - 1); + Atomics.store(wrappedControls, MSB1_CONTROL.WRITE_INDEX, (1 << 30) - 1); + wrappedWriter.reserve(1)[0].set([9]); + wrappedWriter.commit({ generation: 1n, startFrame: 0n, frames: 1, endOfRegion: false }); + assert.equal(wrappedControls[MSB1_CONTROL.WRITE_INDEX], 0); + assert.equal(wrappedWriter.occupancy, 1); + Atomics.store(wrappedControls, MSB1_CONTROL.READ_INDEX, 0); + wrappedWriter.reserve(1)[0].set([8]); + wrappedWriter.commit({ generation: 1n, startFrame: 1n, frames: 1, endOfRegion: false }); + assert.equal(wrappedControls[MSB1_CONTROL.WRITE_INDEX], 1); + assert.equal(wrappedWriter.occupancy, 1); +}); + +function attachNode({ onMessage = () => {}, onDisconnect = () => {} } = {}) { + return { port: { postMessage: onMessage }, disconnect: onDisconnect }; +} + +test("feed lifecycle preserves URL, typed seams, identity and terminal cleanup", async () => { + const urls = []; + await prepareEngineFeed({ audioWorklet: { addModule: async (url) => urls.push(url) } }); + await prepareEngineFeed({ audioWorklet: { addModule: async (url) => urls.push(url) } }, "https://example.test/feed.js"); + assert.equal(urls[0], String(BUNDLED_ENGINE_ASSETS.pcmFeedWorklet)); + assert.equal(urls[1], "https://example.test/feed.js"); + await assert.rejects(prepareEngineFeed({ audioWorklet: { addModule: async () => { throw new Error("no module"); } } }), (error) => error instanceof PcmFeedError && error.operation === "moduleLoad"); + + let defaultConstructed = false; + const oldNode = globalThis.AudioWorkletNode; + globalThis.AudioWorkletNode = class { constructor() { defaultConstructed = true; } }; + const posted = []; + let factoryContext; + let factoryOptions; + const node = attachNode({ onMessage: (message) => posted.push(message) }); + const sources = [{ sourceId: "mono", channels: 1 }, { sourceId: "stereo", channels: 2 }, { sourceId: "tail", channels: 1 }]; + const feed = attachEngineFeed({ context, sources, quantumFrames: 4, createNode: (received, name, options) => { factoryContext = received; factoryOptions = { name, options }; return node; } }); + assert.equal(defaultConstructed, false); + assert.equal(factoryContext, context); + assert.deepEqual(factoryOptions, { name: "miso-sab-feed-attach", options: { numberOfInputs: 0, numberOfOutputs: 1 } }); + assert.equal(posted[0].op, "attach"); + assert.deepEqual(posted[0].rings, feed.rings); + assert.deepEqual(feed.rings.map((ring) => controls(ring)[MSB1_CONTROL.FRAME_CAPACITY]), [4, 4, 4]); + assert.deepEqual(feed.rings.map((ring) => controls(ring)[MSB1_CONTROL.CAPACITY]), [64, 64, 64]); + for (const ring of feed.rings) Atomics.store(controls(ring), MSB1_CONTROL.ATTACHED, 1); + await feed.ready(); + assert.equal(feed.state, "active"); + assert.throws(() => attachEngineFeed({ + context, + sources: [{ sourceId: "node-failure", channels: 1 }], + quantumFrames: 4, + createNode: () => { throw new Error("node unavailable"); }, + }), (error) => error instanceof PcmFeedError && error.operation === "nodeCreate"); + const empty = attachEngineFeed({ context, sources: [], quantumFrames: 4, createNode: () => attachNode() }); + await empty.ready(); assert.equal(empty.state, "active"); + globalThis.AudioWorkletNode = oldNode; + + let waits = 0; let enteredResolve; + const entered = new Promise((resolve) => { enteredResolve = resolve; }); + const never = new Promise(() => {}); + let disconnects = 0; let detaches = 0; + const blockedNode = attachNode({ onMessage: (message) => { if (message.op === "detach") detaches += 1; }, onDisconnect: () => { disconnects += 1; } }); + const blocked = attachEngineFeed({ context, sources: [{ sourceId: "blocked", channels: 1 }], quantumFrames: 4, createNode: () => blockedNode }); + new Msb1RingWriter(blocked.rings[0]).engage(1n); + const blockedWait = async () => { + waits += 1; + if (waits === 2) enteredResolve(); + return never; + }; + const first = blocked.ready({ wait: blockedWait }); + const second = blocked.ready({ wait: blockedWait }); + await settleWithin(entered); + blocked.close(); blocked.close(); + const blockedResults = await settleWithin(Promise.allSettled([first, second])); + assert.deepEqual(blockedResults.map((result) => result.status === "rejected" && result.reason.operation), ["closed", "closed"]); + assert.equal(disconnects, 1); assert.equal(detaches, 1); + assert.equal(controls(blocked.rings[0])[MSB1_CONTROL.WRITER_STATE], 0); + + let clock = 0; let timeoutDisconnects = 0; let timeoutDetaches = 0; + const timed = attachEngineFeed({ context, sources: [{ sourceId: "timeout", channels: 1 }], quantumFrames: 4, createNode: () => attachNode({ onMessage: (message) => { if (message.op === "detach") timeoutDetaches += 1; }, onDisconnect: () => { timeoutDisconnects += 1; } }) }); + new Msb1RingWriter(timed.rings[0]).engage(1n); + let ownerEnteredResolve; let otherEnteredResolve; + const ownerEntered = new Promise((resolve) => { ownerEnteredResolve = resolve; }); + const otherEntered = new Promise((resolve) => { otherEnteredResolve = resolve; }); + let releaseOwnerWait; + const ownerWait = new Promise((resolve) => { releaseOwnerWait = resolve; }); + const timing = timed.ready({ timeoutMs: 1, now: () => clock, wait: async () => { ownerEnteredResolve(); await ownerWait; } }); + await settleWithin(ownerEntered); + const waiting = timed.ready({ timeoutMs: 1, now: () => clock, wait: async () => { otherEnteredResolve(); return never; } }); + await settleWithin(otherEntered); + clock = 2; + releaseOwnerWait(); + const timeoutResults = await settleWithin(Promise.allSettled([timing, waiting])); + assert.deepEqual(timeoutResults.map((result) => result.status === "rejected" && result.reason.operation), ["readyTimeout", "closed"]); + assert.equal(timeoutDisconnects, 1); assert.equal(timeoutDetaches, 1); + assert.equal(controls(timed.rings[0])[MSB1_CONTROL.WRITER_STATE], 0); + timed.close(); + assert.equal(timeoutDisconnects, 1); assert.equal(timeoutDetaches, 1); +}); + +test("attach-post cleanup releases engaged rings before throwing cleanup failures", () => { + let messageRings = []; + let disconnectStates = []; + let disconnects = 0; + let detaches = 0; + assert.throws(() => attachEngineFeed({ + context, + sources: [{ sourceId: "a", channels: 1 }, { sourceId: "b", channels: 2 }, { sourceId: "c", channels: 1 }], + quantumFrames: 4, + createNode: () => attachNode({ + onMessage: (message) => { + if (message.op === "attach") { + messageRings = message.rings; + for (const ring of message.rings) Atomics.store(controls(ring), MSB1_CONTROL.WRITER_STATE, 1); + throw new Error("attach blocked"); + } + if (message.op === "detach") detaches += 1; + throw new Error("detach blocked"); + }, + onDisconnect: () => { disconnects += 1; disconnectStates = messageRings.map((ring) => controls(ring)[MSB1_CONTROL.WRITER_STATE]); throw new Error("disconnect blocked"); }, + }), + }), (error) => error instanceof PcmFeedError && error.operation === "attachPost"); + assert.equal(disconnects, 1); + assert.equal(detaches, 1); + assert.ok(messageRings.length === 3); + assert.deepEqual(disconnectStates, [0, 0, 0]); + assert.deepEqual(messageRings.map((ring) => controls(ring)[MSB1_CONTROL.WRITER_STATE]), [0, 0, 0]); +}); + +function preludeHarness(source, { tracking = false } = {}) { + const registrations = new Map(); + const allocations = []; + let armed = false; + class AudioWorkletProcessorFake { constructor() { this.port = { onmessage: null }; } } + const sandbox = { SharedArrayBuffer, Int32Array, BigInt64Array, Uint8Array, Float32Array, Atomics, TextDecoder, AudioWorkletProcessor: AudioWorkletProcessorFake, registerProcessor(name, constructor) { registrations.set(name, constructor); } }; + for (const name of ["Int8Array", "Uint8Array", "Uint8ClampedArray", "Int16Array", "Uint16Array", "Int32Array", "Uint32Array", "Float32Array", "Float64Array", "BigInt64Array", "BigUint64Array"]) { + const Base = globalThis[name]; + sandbox[name] = class extends Base { + constructor(...args) { super(...args); if (tracking && armed) allocations.push(name); } + subarray(...args) { if (tracking && armed) allocations.push(`${name}.subarray`); return super.subarray(...args); } + slice(...args) { if (tracking && armed) allocations.push(`${name}.slice`); return super.slice(...args); } + }; + } + sandbox.globalThis = sandbox; + vm.runInNewContext(source, sandbox); + return { sandbox, registrations, allocations, arm: () => { armed = true; } }; +} + +function runPrelude(source, { tracking = false, mutate = false } = {}) { + const mutated = mutate ? source.replace( + "const staging = this.sourcePcm", + "if (control[CONTROL_WROTE] > 1) new Float32Array(4); const staging = this.sourcePcm", + ) : source; + const { sandbox, registrations, allocations, arm } = preludeHarness(mutated, { tracking }); + const submissions = []; const seeks = []; let submitResult = 0; let seekResult = 0; + class Engine { + constructor() { + this.quantumFrames = 4; this.maximumSourceChannels = 2; this.memoryBuffer = new ArrayBuffer(65_536); + this.sourceIdPointer = 0; this.sourceIdCapacity = 128; this.sourcePcm = new sandbox.Float32Array(this.memoryBuffer, 1024, 8); + this.handle = 1; this.ready = true; this.disposed = false; this.stickyResult = 0; + this.exports = { memory: { buffer: this.memoryBuffer }, miso_engine_web_v1_source_seek: (_h, _id, generation, frame) => { seeks.push([generation, frame]); return seekResult; }, miso_engine_web_v1_source_submit: (_h, _id, generation, start, channels, frames, end) => { submissions.push({ generation, start, channels, frames, end, pcm: [...this.sourcePcm] }); return submitResult; } }; + } + process() { 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 rings = [1, 2, 1].map((channels, index) => createMsb1Ring({ sourceId: `source-${index}`, channels, frameCapacity: 4, capacity: 2 })); + const ringControls = rings.map(controls); + attach.port.onmessage({ data: { op: "attach", rings } }); + const writers = rings.map((ring) => new Msb1RingWriter(ring)); + for (const [index, writer] of writers.entries()) { + writer.engage(1n); + const planes = writer.reserve(3); + planes[0].set([index + 1, 2, 3]); + if (writer.channels === 2) planes[1].set([4, 5, 6]); + writer.commit({ generation: 1n, startFrame: 0n, frames: 3, endOfRegion: true }); + } + const process = () => engine.process([], []); + if (tracking) arm(); + return { sandbox, allocations, engine, attach, rings, ringControls, writers, submissions, seeks, process, setSubmitResult: (value) => { submitResult = value; }, setSeekResult: (value) => { seekResult = value; } }; +} + +test("moved prelude drains odd mono/stereo rings and allocation mutation turns red", 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, { tracking: true }); + run.process(); + run.process(); + assert.deepEqual(run.submissions.map(({ channels, frames, end }) => ({ channels, frames, end })), [ + { channels: 1, frames: 3, end: 1 }, { channels: 2, frames: 3, end: 1 }, { channels: 1, frames: 3, end: 1 }, + ]); + assert.deepEqual(run.submissions[1].pcm, [2, 2, 3, 0, 4, 5, 6, 0]); + assert.ok(Object.is(run.submissions[1].pcm[3], 0)); + assert.ok(Object.is(run.submissions[1].pcm[7], 0)); + assert.deepEqual(run.submissions.map(({ generation, start, channels, frames, end }) => ({ generation, start, channels, frames, end })), [ + { generation: 1n, start: 0n, channels: 1, frames: 3, end: 1 }, + { generation: 1n, start: 0n, channels: 2, frames: 3, end: 1 }, + { generation: 1n, start: 0n, channels: 1, frames: 3, end: 1 }, + ]); + assert.ok(run.ringControls.every((control) => control[MSB1_CONTROL.SUBMITTED] === 1)); + run.setSubmitResult(6); const writer = run.writers[0]; const plane = writer.reserve(4); plane[0].set([9, 8, 7, 6]); writer.commit({ generation: 1n, startFrame: 3n, frames: 4, endOfRegion: false }); + run.process(); + assert.equal(writer.occupancy, 1); assert.equal(run.ringControls[0][MSB1_CONTROL.REFUSED], 0); + assert.deepEqual(run.submissions.at(-1), { generation: 1n, start: 3n, channels: 1, frames: 4, end: 0, pcm: [9, 8, 7, 6, 4, 5, 6, 0] }); + assert.equal(run.submissions.length, 4, "backpressure must capture the refused submit attempt"); + run.setSubmitResult(0); run.process(); assert.equal(writer.occupancy, 0); + assert.equal(run.submissions.length, 5, "draining the retained slot must invoke source_submit again"); + assert.deepEqual(run.submissions.at(-1), { generation: 1n, start: 3n, channels: 1, frames: 4, end: 0, pcm: [9, 8, 7, 6, 4, 5, 6, 0] }); + writer.reserve(4)[0].fill(5); writer.commit({ generation: 1n, startFrame: 7n, frames: 4, endOfRegion: false }); + writer.seek(2n, 12n); run.setSeekResult(6); run.process(); assert.equal(run.ringControls[0][MSB1_CONTROL.SEEKS_APPLIED], 0); assert.equal(writer.occupancy, 1); + assert.deepEqual(run.seeks, [[2n, 12n]]); + run.setSeekResult(0); run.process(); assert.equal(run.ringControls[0][MSB1_CONTROL.SEEKS_APPLIED], 1); assert.equal(run.ringControls[0][MSB1_CONTROL.STALE], 1); assert.equal(writer.occupancy, 0); + assert.deepEqual(run.seeks, [[2n, 12n], [2n, 12n]]); + assert.equal(run.submissions.some(({ start }) => start === 7n), false); + run.process(); assert.ok(run.ringControls[0][MSB1_CONTROL.UNDERRUNS] > 0); + run.attach.port.onmessage({ data: { op: "detach" } }); assert.ok(run.ringControls.every((control) => control[MSB1_CONTROL.ATTACHED] === 0)); + assert.deepEqual(run.allocations, [], "first, later, partial and retry drains must allocate no typed arrays or views"); + const mutant = runPrelude(source, { tracking: true, mutate: true }); + mutant.process(); + mutant.process(); + const mutantWriter = mutant.writers[0]; + mutant.setSubmitResult(0); + mutantWriter.reserve(4)[0].fill(1); + mutantWriter.commit({ generation: 1n, startFrame: 4n, frames: 4, endOfRegion: false }); + mutant.process(); + assert.ok(mutant.allocations.includes("Float32Array"), "runtime mutation must be caught by constructor instrumentation"); +}); diff --git a/sdk/test/package-tarball-smoke.mjs b/sdk/test/package-tarball-smoke.mjs index 0816e9f9d..8ae5bbad9 100644 --- a/sdk/test/package-tarball-smoke.mjs +++ b/sdk/test/package-tarball-smoke.mjs @@ -32,7 +32,15 @@ for (const subpath of [".", "./headless", "./browser", "./assets"]) { assert.equal(typeof imported["."].session, "function"); assert.equal(typeof imported["./headless"].createOfflineEngine, "function"); assert.equal(typeof imported["./browser"].createEngine, "function"); +assert.equal(typeof imported["./browser"].attachEngineFeed, "function"); +assert.equal(typeof imported["./browser"].prepareEngineFeed, "function"); assert.ok(imported["./assets"].BUNDLED_ENGINE_ASSETS.wasm instanceof URL); +assert.ok(imported["./assets"].BUNDLED_ENGINE_ASSETS.pcmFeedWorklet instanceof URL); +const shippedNotice = await readFile(resolve(packageRoot, "dist/NOTICE"), "utf8"); +assert.match(shippedNotice, /engine-web-adapter/); +assert.match(shippedNotice, /bd7f330a9773ce43bb077f0e6d5c8fc30fe9e27c/); +assert.match(shippedNotice, /7485693e9bbcf2f65a91a4e5950e22d678d99062/); +assert.match(shippedNotice, /63b4ee6212287000ff85e1cfa969d385f6246d2d/); for (const [subpath, module] of Object.entries(imported)) { assert.equal( "canonicalSessionJson" in module, @@ -66,6 +74,8 @@ const files = await readdir(packageRoot, { recursive: true }); assert.ok(files.includes("dist/assets/miso-engine-v1-audio-worklet.simd128.wasm")); assert.equal(files.some((name) => /flac|decoder|cli\/stems/i.test(name)), false, "the archive has no retired delivery payload"); assert.ok(files.includes("dist/LICENSE")); +assert.ok(files.includes("dist/NOTICE")); +assert.ok(files.includes("dist/assets/miso-engine-v1-pcm-feed-worklet.js")); assert.equal(files.some((name) => name.includes("node_modules")), false); assert.equal(files.some((name) => name.startsWith("test") || name.includes("/test/")), false); assert.equal(files.includes("dist/effect.js"), false); @@ -91,13 +101,27 @@ const consumer = resolve(consumerRoot, "index.ts"); await writeFile(consumer, ` import { CATALOG, session } from "@misofm/engine"; import { createOfflineEngine, loadBundledEngineAsset } from "@misofm/engine/headless"; -import { createEngine } from "@misofm/engine/browser"; +import { createEngine, prepareEngineFeed, attachEngineFeed, Msb1RingWriter } from "@misofm/engine/browser"; import type { BrowserEngine } from "@misofm/engine/browser"; import { BUNDLED_ENGINE_ASSETS } from "@misofm/engine/assets"; // @ts-expect-error arbitrary-model canonical serialization is intentionally not public import { canonicalSessionJson } from "@misofm/engine"; void [CATALOG, session, createOfflineEngine, loadBundledEngineAsset, createEngine, - BUNDLED_ENGINE_ASSETS, canonicalSessionJson]; + prepareEngineFeed, attachEngineFeed, BUNDLED_ENGINE_ASSETS, canonicalSessionJson]; +declare const domContext: BaseAudioContext; +const domFactory = (context: BaseAudioContext, name: string, options: AudioWorkletNodeOptions): AudioWorkletNode => + new AudioWorkletNode(context, name, options); +const packedFeed = attachEngineFeed({ + context: domContext, + sources: [{ sourceId: "packed-source", channels: 2 }], + quantumFrames: 128, + createNode: domFactory, +}); +void packedFeed.rings; +const packedWriter = new Msb1RingWriter(packedFeed.rings[0]); +packedWriter.engage(1n); +void packedWriter; +void prepareEngineFeed(domContext, BUNDLED_ENGINE_ASSETS.pcmFeedWorklet); declare const browser: BrowserEngine; const host = browser.host; void host.command({ commands: [] }); @@ -127,6 +151,33 @@ assert.deepEqual( "a fresh strict TypeScript consumer resolves every declaration dependency", ); +const feedModuleUrls = []; +const packedContext = { audioWorklet: { addModule: async (url) => feedModuleUrls.push(String(url)) } }; +await imported["./browser"].prepareEngineFeed(packedContext); +await imported["./browser"].prepareEngineFeed(packedContext, "https://example.test/explicit-feed.js"); +assert.equal(feedModuleUrls[0], String(imported["./assets"].BUNDLED_ENGINE_ASSETS.pcmFeedWorklet)); +assert.equal(feedModuleUrls[1], "https://example.test/explicit-feed.js"); +let packedAttach; +let packedDisconnects = 0; +const packedNode = { + port: { postMessage(message) { if (message.op === "attach") packedAttach = message; } }, + disconnect() { packedDisconnects += 1; }, +}; +const packedFeedRuntime = imported["./browser"].attachEngineFeed({ + context: packedContext, + sources: [{ sourceId: "packed-mono", channels: 1 }, { sourceId: "packed-stereo", channels: 2 }], + quantumFrames: 4, + createNode: () => packedNode, +}); +assert.deepEqual(packedAttach.rings, packedFeedRuntime.rings); +assert.deepEqual(packedFeedRuntime.rings.map((ring) => new Int32Array(ring)[2]), [64, 64]); +for (const ring of packedFeedRuntime.rings) Atomics.store(new Int32Array(ring), 13, 1); +await packedFeedRuntime.ready(); +packedFeedRuntime.close(); +packedFeedRuntime.close(); +assert.equal(packedDisconnects, 1); +assert.ok(files.includes("dist/assets/miso-engine-v1-pcm-feed-worklet.js")); + const builtDocument = imported["."].session({ id: "tarball.boot", sampleRateHz: 48_000 }) .source("stem", { channels: 2, bitDepth: "32f", frames: 480, content: `sha256:${"0".repeat(64)}`,