diff --git a/.github/ISSUE_SPECS/719-prepare-browser-dsp-and-bound-pcm-feed.md b/.github/ISSUE_SPECS/719-prepare-browser-dsp-and-bound-pcm-feed.md new file mode 100644 index 000000000..a01ae49f0 --- /dev/null +++ b/.github/ISSUE_SPECS/719-prepare-browser-dsp-and-bound-pcm-feed.md @@ -0,0 +1,52 @@ +# Prepare browser DSP off-thread and bound first-callback PCM feed work + +Base: engine origin/main 0df770b0f1f002b563246db70b71a3991fe8a8c7; public engine SDK 0.2.1. The user authorizes Astra xhigh to continue the scalable 64-track effort and iterate the cold-start overrun before release. No legacy engine source, DSP algorithm changes, storage/download changes, or package publication is in this implementation tranche. + +## Measured problem and scope + +Three separate Chromium processes with the actual 64-track app EQ/compressor graph, current unbatched canonical storage, all 737280000 PCM bytes independently verified, physical HTTP concurrency four, meters and telemetry each show exactly one cold first-window render overrun (8/5/8ms). Warm inner-render telemetry is clean. Trace attribution identifies 49 synchronous Wasm lazy-compilation events inside the first DSP render totaling4.322ms. The complete feed-plus-render callback also costs about8ms warm: the existing feed greedily fills internal queues from complete shared runways in one callback. + +Copied-current-package experiments preserve full verification and feed oracles. A disposed/terminated scratch rehearsal still misses5ms live. Retaining its compiled asset through live-host construction removes live synchronous compilation. Combining that with at most two successful fresh submissions per source per callback gives first complete callbacks1.516ms cold/2.679ms warm, with zero feed failures. The warm trace remains12microseconds above the128-frame/48k deadline; preserve that residual and the effect of instrumentation, rather than rounding it into a perfect result. Local evidence and failed variants: app docs/analysis/mixer-64-cold-start-results.md and e2e/results/mixer-startup-combined-{poc,attribution}-0.json. + +Smallest product slice: move default adapter-consumed first-use DSP preparation to a disposable worker, strongly own and reuse its exact compiled module in the live host, and bound the PCM work that precedes every render. Both components are necessary to address the named first-callback budget. Existing APIs remain compatible; no second benchmark framework is needed. + +## Design and compatibility + +1. Add a browser preparation API returning the validated session shape and a strongly owned WebAssembly.Module. Boot the exact document/policy in the existing bounded worker, rehearse64 fixed quanta with bounded reusable synthetic nonzero source planes and metering when available, dispose the scratch instance, return the module by structured clone, and physically terminate before completion is observable. No real stem is read or marked ready by rehearsal. There is no compiled track-count cap: source count remains constrained by the existing declared resource policy; rehearsal duration is bounded by64 quanta and the existing worker deadline. +2. Expose the compiled module from its existing verified asset owner without recompiling, instantiating or changing its bytes. The live host accepts this prepared module and passes the same module into its AudioWorklet; it must not fetch/recompile another module on that path. The live instance starts at sample0 with fresh DSP state. Preserve existing URL-only host construction, shape-only scratch APIs and custom scratch/host overrides. Module ownership is explicit rather than an assumption about browser code-cache lifetime. +3. In the current SDK PCM feed, admit at most two successful fresh submissions per source per process call; scan at most that source ring's capacity. Count only accepted fresh chunks against the submission allowance. Retain unconsumed backpressure slots, source generations, stale release, exact short-tail PCM/EOF flags and error counters. Existing internal queue capacity is unchanged and fills gradually; the all-source shared runway remains available. +4. Adapter adoption is a downstream tranche after this engine capability is reviewed: retain the preparation result through complete source verification and pass its module into live host construction. Do not copy current engine worklet code into adapter runtime. Root coordinates exact version pins and the engine-to-adapter-to-app release pipeline after packed qualification. + +## Objective gates and review + +Focused SDK tests prove one compilation and exact module identity through worker/host handoff, shape/policy equality, scratch disposal and physical worker termination on success/failure/abort/deadline, compatibility of old scratch requests and URL-only host calls, and live sample0/fresh state. Rehearsal covers short/zero-length tails without fabricating real readiness, different source/channel counts, console-disabled policies and errors. The feed gate proves per-source admission bounds, capacity-bounded scanning, eventual prior internal queue depth, independent sources, actual backpressure retention, generation-safe seek/stale retirement, exact PCM and EOF, and unchanged continuous playback. + +Run proportional SDK type/generated/source/browser PCM and package gates, then a fresh packed consumer against the exact artifact. Root adapter qualification preserves initial/paused/running exact-first-output, full contiguous generation runways and all191 lifecycle tests. Final64-track app effects playback crosses full source EOF and repeats seeks cold/warm, with physical HTTP concurrency four, full independent canonical verification and zero underruns/refused/torn/errors. Measure the complete feed-and-render callback using the existing high-resolution trace path in addition to inner telemetry; do not infer a deadline pass from a1ms clock. No perfect cross-browser claim: physical iPad/Safari remains a separate qualification limit. + +A fresh Astra xhigh reviewer verifies the spec before implementation and each coherent green checkpoint. Keep local checkpoints on one batch branch; no publication or deployment until exact packed artifacts and root review are ready. Research helpers are evidence, not production package files. + +## Status + +Spec prepared before implementation. The existing adapter opening-attachment cancellation blocker is already fixed and independently reviewed in adapter6dbc3c8; full191 tests and packed healthy/fault gates pass. Its v2 archive is retained separately. Engine production implementation has not started. + +Fresh Astra xhigh spec review approves beginning the bounded tranche. Clarifications: module identity is local identity at each send boundary, not JavaScript reference equality across structured-clone realms; prove one worker compilation and no host refetch/recompile, then real-browser no-live-lazy trace after worker termination. Snapshot document bytes and nested policy words before async work so caller mutation cannot skew scratch and live boot. Exercise same-turn reply/abort ordering and module-clone/post failures, with physical termination before either public outcome. The 2.679ms instrumented warm residual remains explicit until final artifact qualification. + +First coherent checkpoint implements the per-source two-accepted submission cap and capacity-bounded scan in the SDK-owned feed. Focused browser PCM gate passes13 tests against freshly built pinned Wasm (`/tmp/miso-engine-startup-artifacts`, log `/tmp/miso-engine-startup-feed-tests.log`). The new queue model starts from complete shared runways, enforces each source's admission allowance, reaches the prior eight-quantum internal capacity gradually, exercises actual capacity backpressure without consuming its pending shared slot, and preserves zero underruns/refusals. Existing seek, generation, tail, odd mono/stereo PCM and allocation-mutation tests remain green. Prepared-module implementation and final packed startup qualification remain pending. + +Prepared-module checkpoint f393c549 adds the explicit retained module API, bounded64-quantum disposable rehearsal, live host no-refetch handoff, owned document/policy snapshots and old scratch compatibility. Astra low implementation passes typecheck and52 focused browser/default tests; the full headless SDK gate passes187 tests (`/tmp/miso-engine-startup-sdk-check.log`). Stateful EQ/compressor/delay fresh-instance output and meter comparisons cover8 quanta, short1/129-frame tails and canonical zero-length refusal, module clone faults, same-turn abort/reply and physical worker termination. Independent Astra xhigh source review and final packed browser startup qualification remain pending. Adapter adoption is separately specified by misofm/engine-web-adapter#52. + +Fresh-state follow-up f011e363 activates EQ+6dB at1kHz and5ms wet feedback delay, compares eight live/fresh quanta after an initial excitation, proves nontrivial delayed output and exact four meter-window sample origins, and prepares four independently routed mixed mono/stereo sources with unequal short tails. All37 focused defaults tests pass (`/tmp/prepared-active-tests.log`). Local release preparation updates SDK/package lock and existing OIDC workflow identity gates to0.2.2; registry still reports0.2.1 and no publish workflow is active. This prepares reviewable artifacts only, and does not authorize or claim publication. Upstream/source audit and full callback browser qualification remain the release gates. + +Independent Astra xhigh implementation review finds no source blocker in the feed, prepared-module/state tests or adapter3daeaef consumption. The existing direct createEngine default remains shape-only; callers composing preparedModule must also reuse the saved shape through scratchBoot to avoid a separate shape worker compilation. The adapter does this explicitly, and the README documents the pair. Production trace-only packaged qualification is being prepared without copied runtime patches. + +## Final exact-package qualification + +Frozen engine0.2.2 source `cf7e695b29043abcae9f92d5172b719e33764f03`, archive `/tmp/miso-engine-0.2.2-reviewed/misofm-engine-0.2.2.tgz`, SHA256 `30639cf1f0f9707534020d584736339da3ef08981af5b6c5148f3ac310261e58`; all79 payloads match the source/build receipt. Pinned current-main Wasm is `5695fbc4d72fae4a78b5acd1cf8970c489163703a11ac5351974ce05a90b1574`. Full final headless gate passes188 tests and publishable-tarball gate passes (`/tmp/miso-engine-0.2.2-headless-final.log`, `/tmp/miso-engine-0.2.2-package-check.log`). Adapter0.3.5 exact consumer passes191 tests and real packed initial/resumed/running first-output/full-runway and terminal read-reject/deadline/crash cleanup gates. + +Production qualification uses exact package extraction, current unbatched writes, unchanged full737280000-byte independent source proof, HTTP concurrency four and actual64-track app EQ/compressor graph with64 meters. It makes no render-source patches, retained-worker substitution or extra rehearsal. A weak module-reference receipt proves the normal native worker terminate call returns before the live worklet receives the exact prepared module, including29.33/13.59 seconds of cold/warm verified-ingestion retention. + +Three fresh processes each pass cold/warm startup: complete first/full trace maxima1.327/2.410ms,1.351/1.828ms,1.371/2.317ms. All6785 traced full callbacks fit the128-frame/48k budget. No realtime Wasm lazy/baseline compilation remains; two JS-to-Wasm wrapper compile events per thread occur outside the first callback, so this is not a blanket zero-compilation claim. Raw reports and independent attribution are app `e2e/results/mixer-production-startup-{qualification,attribution}-{0,1,2}.json`. + +The long production run crosses EOF for62 seconds then performs8 two-second seeks in each cache phase; feed and transport pass across64 sources. One warm inner-telemetry window227 reports3 coarse-clock misses (peak4ms), about1.35 seconds into seek8. The initial trace did not cover it. A separate unchanged long-history run tracing that eighth seek passes all456 telemetry windows, all source/feed gates, and1510 complete late callbacks with cold/warm maxima1.086/1.104ms. No realtime compilation or GC occurs in those late traces; the earlier observation did not reproduce and its cause is unproven. The worklet clock falls back to Date.now at1ms resolution; do not infer an actual monotonic callback duration or clock-adjustment cause from that alone. Preserve both reports: `mixer-production-late-seek-{qualification,attribution}-0.json`. + +Independent Astra xhigh source review finds no implementation blocker. Nine research-helper synthetic tests and ESLint pass. The app report `docs/analysis/mixer-64-cold-start-results.md` retains exact evidence, failed PoCs and limits. The named startup defect and sustained feed architecture are qualified in this Chromium fixture; physical iPad/Safari and arbitrary scheduler-stall immunity are not claimed. Root owns main merge, OIDC qualification/publication, registry artifact verification, adapter exact adoption/publication and required app integration checks. No publication or deployment was performed by this implementation agent. diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 5b1ef7954..d9a5ea1d4 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -29,14 +29,14 @@ concurrency: env: PACKAGE_NAME: "@misofm/engine" - PACKAGE_VERSION: "0.2.1" - EXPECTED_WORKLET_SHA256: "54dcf7dd5f6199cf3ceeab77afefe09067e18b730c9e0a6ef9df73fbfd3afc69" + PACKAGE_VERSION: "0.2.2" + EXPECTED_WORKLET_SHA256: "5695fbc4d72fae4a78b5acd1cf8970c489163703a11ac5351974ce05a90b1574" RUSTUP_TOOLCHAIN: "1.97.1" NPM_CONFIG_REGISTRY: "https://registry.npmjs.org" jobs: release: - name: ${{ inputs.mode }} @misofm/engine 0.2.1 + name: ${{ inputs.mode }} @misofm/engine 0.2.2 runs-on: ubuntu-24.04 steps: - name: Refuse non-main dispatches before checkout @@ -124,7 +124,7 @@ jobs: const jobs = JSON.parse(fs.readFileSync(process.argv[3], "utf8")); const artifacts = JSON.parse(fs.readFileSync(process.argv[4], "utf8")); const expectedArtifact = `engine-sdk-qualify-${process.env.EXPECTED_SHA}`; - const qualificationJobs = Array.isArray(jobs.jobs) ? jobs.jobs.filter((job) => job.name === "qualify @misofm/engine 0.2.1" && job.conclusion === "success") : []; + const qualificationJobs = Array.isArray(jobs.jobs) ? jobs.jobs.filter((job) => job.name === "qualify @misofm/engine 0.2.2" && job.conclusion === "success") : []; const namedArtifacts = Array.isArray(artifacts.artifacts) ? artifacts.artifacts.filter((artifact) => artifact.name === expectedArtifact && artifact.expired === false) : []; if (run.conclusion !== "success" || run.event !== "workflow_dispatch" || run.head_sha !== process.env.EXPECTED_SHA || run.name !== "Publish @misofm/engine" || run.path !== ".github/workflows/npm-publish.yml" || qualificationJobs.length !== 1 || namedArtifacts.length !== 1) { throw new Error("qualification run is not a successful manual npm-publish run for expected_sha"); @@ -200,7 +200,7 @@ jobs: const entries = JSON.parse(fs.readFileSync(packJson, "utf8")); if (!Array.isArray(entries) || entries.length !== 1) throw new Error("npm pack did not report exactly one archive"); const item = entries[0]; - if (item.name !== "@misofm/engine" || item.version !== "0.2.1") throw new Error("packed package identity differs"); + if (item.name !== "@misofm/engine" || item.version !== "0.2.2") throw new Error("packed package identity differs"); if (typeof item.integrity !== "string" || !item.integrity.startsWith("sha512-")) throw new Error("npm did not report SHA-512 integrity"); const bytes = fs.readFileSync(archive); const sha1 = crypto.createHash("sha1").update(bytes).digest("hex"); @@ -336,7 +336,7 @@ jobs: let statement; try { statement = JSON.parse(Buffer.from(envelope.payload, "base64").toString("utf8")); } catch { throw new Error("verified attestation DSSE payload is not valid base64 JSON"); } if (statement?._type !== "https://in-toto.io/Statement/v1" || statement?.predicateType !== "https://slsa.dev/provenance/v1") throw new Error("DSSE statement is not SLSA provenance v1"); - const subjects = Array.isArray(statement.subject) ? statement.subject.filter((subject) => subject?.name === "pkg:npm/%40misofm/engine@0.2.1" && subject?.digest?.sha512 === local.sha512) : []; + const subjects = Array.isArray(statement.subject) ? statement.subject.filter((subject) => subject?.name === "pkg:npm/%40misofm/engine@0.2.2" && subject?.digest?.sha512 === local.sha512) : []; if (subjects.length !== 1) throw new Error("DSSE statement does not uniquely bind the package PURL and tarball SHA-512"); const workflow = statement?.predicate?.buildDefinition?.externalParameters?.workflow; if (workflow?.repository !== "https://github.com/misofm/engine" || workflow?.path !== ".github/workflows/npm-publish.yml" || workflow?.ref !== "refs/heads/main") throw new Error("DSSE workflow identity differs from the trusted publisher"); diff --git a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts index ab1722cf5..334ee1ba7 100644 --- a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts +++ b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts @@ -659,6 +659,7 @@ export interface CreateMisoAudioWorkletHostOptions { document: Uint8Array; options: MisoWebBootOptions; simd128ModuleUrl: string; + preparedModule?: WebAssembly.Module; workletModuleUrl: string; } diff --git a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.js b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.js index cc8ac9029..1aec106fd 100644 --- a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.js +++ b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.js @@ -826,7 +826,8 @@ class MisoAudioWorkletHost { export async function createMisoAudioWorkletHost(options) { const quantumFrames = options?.context?.renderQuantumSize ?? 128; - if (!hasExactFields(options, OPTION_FIELDS) + if (!hasExactFields(options, options?.preparedModule === undefined ? OPTION_FIELDS : [...OPTION_FIELDS, "preparedModule"]) + || (options.preparedModule !== undefined && !(options.preparedModule instanceof WebAssembly.Module)) || options.context?.state !== "suspended" || !validU32(quantumFrames) || quantumFrames === 0 || !validU32(options.context?.sampleRate) || options.context.sampleRate === 0 @@ -843,7 +844,7 @@ export async function createMisoAudioWorkletHost(options) { try { const selected = { backend: SHIPPING_BACKEND, - module: await fetchModule(options.simd128ModuleUrl), + module: options.preparedModule ?? await fetchModule(options.simd128ModuleUrl), }; await options.context.audioWorklet.addModule(options.workletModuleUrl); node = new AudioWorkletNode(options.context, PROCESSOR_NAME, { diff --git a/sdk/README.md b/sdk/README.md index dbe298989..f49343e6b 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -210,6 +210,20 @@ and `createDefaultHost` imports and invokes the shipped host with `toWebBootOpti `scratchBootInWorker` remains the low-level primitive for custom Worker entries. The browser helpers install no PCM feed or storage service. +`prepareBrowserSessionWithWorker` additionally rehearses 64 fixed quanta of synthetic PCM in the +throwaway instance and returns `{ shape, module }`. The instance is disposed and Worker terminated +before the result resolves. Retain `module` while resolving and fully verifying real source data, +then pass it as `preparedModule` to `createEngine`, with `scratchBoot: async () => shape`. The live +host reuses that compiled module without a second Wasm fetch or compilation, and boots fresh DSP +state at sample 0. Keep the same owned document and policy snapshot across preparation and live +creation; the helpers copy inputs at each call boundary. `prepareBrowserSessionInWorker` is the +corresponding primitive for custom Worker entries. This preparation proves no real source ready +and performs no source delivery. The existing shape-only defaults and URL-only host remain valid. + +The SDK PCM feed accepts at most two fresh submissions per source per render callback and scans +at most that source ring's capacity. Shared source runways remain available while internal queues +fill gradually, avoiding a first-callback burst proportional to every queued source quantum. + `await engine.console()` binds the same semantic console shown above to the shipped browser host. It resolves the browser session map once, then submits the same whole-batch edits over MessagePort. All eleven live command kinds are available without numeric rack, channel, parameter, or tap IDs; diff --git a/sdk/package-lock.json b/sdk/package-lock.json index 5aaf12eaf..96cc468aa 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@misofm/engine", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@misofm/engine", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "bin": { "enginectl": "dist/enginectl.js" diff --git a/sdk/package.json b/sdk/package.json index a70552d25..6e58ce5a3 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@misofm/engine", - "version": "0.2.1", + "version": "0.2.2", "description": "Typed Engine V1 SDK for agentic music production", "license": "Apache-2.0", "type": "module", diff --git a/sdk/src/browser-assets/miso-engine-v1-pcm-feed-worklet.js b/sdk/src/browser-assets/miso-engine-v1-pcm-feed-worklet.js index 13086300e..f103dd6d7 100644 --- a/sdk/src/browser-assets/miso-engine-v1-pcm-feed-worklet.js +++ b/sdk/src/browser-assets/miso-engine-v1-pcm-feed-worklet.js @@ -346,8 +346,12 @@ function wrapEngineProcessor(Base) { const staging = this.sourcePcm const quantumFrames = this.quantumFrames let read = control[CONTROL_READ_INDEX] + let accepted = 0 + let remaining = ring.capacity - while (read !== write) { + // Fill internal queues gradually. Complete shared runways must not turn + // one startup/seek callback into thousands of source submissions. + while (read !== write && accepted < 2 && remaining-- > 0) { const slot = read & capacityMask const word = slot * (SLOT_HEADER_BYTES / 4) if (ring.headers[word + SLOT_SEQUENCE] !== read) { @@ -404,6 +408,7 @@ function wrapEngineProcessor(Base) { ring.headers[word + SLOT_GENERATION_TAG] ) control[CONTROL_SUBMITTED] += 1 + accepted += 1 ring.depth += 1 if ((flags & FLAG_END_OF_REGION) !== 0) { ring.finished = true diff --git a/sdk/src/browser/default-host.ts b/sdk/src/browser/default-host.ts index 12a50d730..925b0e456 100644 --- a/sdk/src/browser/default-host.ts +++ b/sdk/src/browser/default-host.ts @@ -20,6 +20,7 @@ export async function createDefaultHost(request: { readonly document: Uint8Array; readonly options: BootOptions; readonly simd128ModuleUrl: string; + readonly preparedModule?: WebAssembly.Module; readonly workletModuleUrl: string; readonly hostModuleUrl?: string; }): Promise { @@ -38,6 +39,7 @@ export async function createDefaultHost(request: { document: request.document, options, simd128ModuleUrl: request.simd128ModuleUrl, + ...(request.preparedModule === undefined ? {} : { preparedModule: request.preparedModule }), workletModuleUrl: request.workletModuleUrl, }); } diff --git a/sdk/src/browser/engine.ts b/sdk/src/browser/engine.ts index ea2e0923b..945ceacc8 100644 --- a/sdk/src/browser/engine.ts +++ b/sdk/src/browser/engine.ts @@ -77,6 +77,7 @@ export interface CreateEngineOptions; readonly simd128ModuleUrl: string; + readonly preparedModule?: WebAssembly.Module; readonly workletModuleUrl: string; }) => Promise; readonly policy?: BrowserBootPolicy; @@ -119,9 +121,7 @@ export interface BrowserEngine { if (typeof document === "string") return new TextEncoder().encode(document); if (document instanceof Uint8Array) { - return document.buffer instanceof ArrayBuffer - ? (document as Uint8Array) - : new Uint8Array(document); + return new Uint8Array(document); } return new TextEncoder().encode(document.toJson()); } @@ -155,7 +155,8 @@ export function createEngine( export function createEngine(options: CreateEngineOptions): Promise>; export async function createEngine(options: CreateEngineOptions): Promise> { const document = documentBytes(options.document); - const policy = options.policy ?? {}; + const policy = { ...options.policy, ...(typeof options.policy?.console === "object" ? { console: { ...options.policy.console } } : {}) }; + const preparedModule = options.preparedModule; const simd128ModuleUrl = options.simd128ModuleUrl ?? BUNDLED_ENGINE_ASSETS.wasm.href; const workletModuleUrl = options.workletModuleUrl ?? BUNDLED_ENGINE_ASSETS.workletModule.href; @@ -229,6 +230,7 @@ export async function createEngine(options: CreateEngineOptions): Promise | undefined; let closePromise: Promise | undefined; @@ -270,8 +272,10 @@ export async function scratchBootInWorker(request: { readonly options: ReturnType; readonly expectedSha256?: string; }): Promise { + const document = new Uint8Array(request.document); + const options = { ...request.options, ...(typeof request.options.console === "object" ? { console: { ...request.options.console } } : {}) }; const asset = await MisoEngineAsset.load(request.moduleBytes, request.expectedSha256); - const boundary = await WasmBoundary.boot(asset, request.document, request.options); + const boundary = await WasmBoundary.boot(asset, document, options); try { return boundary.shape(); } finally { @@ -281,6 +285,43 @@ export async function scratchBootInWorker(request: { } } +/** Compile and rehearse only disposable DSP state; retain the compiled code for live boot. */ +export async function prepareBrowserSessionInWorker(request: Parameters[0]): Promise { + const document = new Uint8Array(request.document); + const options = { ...request.options, ...(typeof request.options.console === "object" ? { console: { ...request.options.console } } : {}) }; + const asset = await MisoEngineAsset.load(request.moduleBytes, request.expectedSha256); + const boundary = await WasmBoundary.boot(asset, document, options); + try { + const shape = boundary.shape(); + const quantum = shape.quantumFrames; + // Only one quantum per channel is retained, regardless of source count or duration. + const plane = new Float32Array(quantum); + for (let frame = 0; frame < quantum; frame++) plane[frame] = 0.125 * Math.sin(frame * 0.13) + 0.0625; + const planesByChannels = new Map(); + const meters = boundary.sessionMap().metersAttached; + if (meters && !boundary.meterLease(true).ok) throw new MisoUsageError("Preparation meter lease refused"); + for (let block = 0; block < 64; block++) { + const startFrame = BigInt(block * quantum); + for (const source of shape.sources) { + if (startFrame >= source.frames) continue; + const frames = Number(source.frames - startFrame < BigInt(quantum) ? source.frames - startFrame : BigInt(quantum)); + let planes = planesByChannels.get(source.channels); + if (planes === undefined) { + planes = Array.from({ length: source.channels }, () => plane); + planesByChannels.set(source.channels, planes); + } + const submitted = boundary.submitSource({ sourceId: source.id, generation: 1n, startFrame, + planes: frames === quantum ? planes : planes.map(channel => channel.subarray(0, frames)), + endOfRegion: startFrame + BigInt(frames) === source.frames }); + if (!submitted.ok) throw new MisoUsageError(`Preparation source submission refused: ${submitted.code}`); + } + boundary.render(quantum); + if (meters) boundary.pollMeters(); + } + return { shape, module: asset.module }; + } finally { boundary.dispose(); } +} + function defaultCreateContext(options: { sampleRate: number; renderSizeHint: number }): AudioContextLike { const constructor = (globalThis as { AudioContext?: new (options: { sampleRate: number; renderSizeHint: number; diff --git a/sdk/src/browser/index.ts b/sdk/src/browser/index.ts index a5b4a9616..d72332fc6 100644 --- a/sdk/src/browser/index.ts +++ b/sdk/src/browser/index.ts @@ -9,6 +9,6 @@ export * from "./host-mirror.ts"; export * from "./pcm-ring.ts"; export * from "./pcm-feed.ts"; -export { scratchBootWithWorker } from "./scratch.ts"; -export type { ScratchWorker, ScratchWorkerFactory } from "./scratch.ts"; +export { scratchBootWithWorker, prepareBrowserSessionWithWorker } from "./scratch.ts"; +export type { ScratchWorker, ScratchWorkerFactory, ScratchBootWorkerOptions, PreparedBrowserSession } from "./scratch.ts"; export { createDefaultHost, BrowserBootError } from "./default-host.ts"; diff --git a/sdk/src/browser/scratch-worker.ts b/sdk/src/browser/scratch-worker.ts index a5be0a2f6..6d65ab6e0 100644 --- a/sdk/src/browser/scratch-worker.ts +++ b/sdk/src/browser/scratch-worker.ts @@ -1,5 +1,5 @@ import { MisoEngineError, MisoUsageError } from "../core/errors.ts"; -import { scratchBootInWorker } from "./engine.ts"; +import { scratchBootInWorker, prepareBrowserSessionInWorker } from "./engine.ts"; import type { ScratchBootReply, ScratchBootRequest } from "./scratch.ts"; @@ -11,7 +11,10 @@ const scope = ((globalThis as unknown as { readonly self?: Scope }).self ?? glob scope.onmessage = (event) => { const request = event.data; - void run(request).then((reply) => scope.postMessage(reply)); + void run(request).then((reply) => { + try { scope.postMessage(reply); } + catch (error) { scope.postMessage(failureReply(request, error)); } + }).catch(() => { /* A failed error post is bounded by the client deadline. */ }); }; scope.postMessage({ type: "worker-ready" }); @@ -19,13 +22,19 @@ async function run(request: ScratchBootRequest): Promise { try { const response = await fetch(request.moduleUrl); if (!response.ok) throw new Error(`Engine Wasm fetch failed with HTTP ${response.status}`); - const shape = await scratchBootInWorker({ + const bootRequest = { moduleBytes: new Uint8Array(await response.arrayBuffer()), document: request.document, options: request.options, - }); - return { type: "scratch-result", requestId: request.requestId, ok: true, shape }; - } catch (error) { + }; + const result = request.type === "prepare" + ? await prepareBrowserSessionInWorker(bootRequest) + : { shape: await scratchBootInWorker(bootRequest) }; + return { type: "scratch-result", requestId: request.requestId, ok: true, ...result }; + } catch (error) { return failureReply(request, error); } +} + +function failureReply(request: ScratchBootRequest, error: unknown): ScratchBootReply { return { type: "scratch-result", requestId: request.requestId, ok: false, error: { @@ -37,5 +46,4 @@ async function run(request: ScratchBootRequest): Promise { } : error instanceof MisoUsageError ? { kind: "usage" as const } : {}), }, }; - } } diff --git a/sdk/src/browser/scratch.ts b/sdk/src/browser/scratch.ts index 74bb52a6c..22edcb26c 100644 --- a/sdk/src/browser/scratch.ts +++ b/sdk/src/browser/scratch.ts @@ -6,7 +6,7 @@ import { BUNDLED_ENGINE_ASSETS } from "../assets.ts"; import { BrowserBootError } from "./default-host.ts"; export interface ScratchBootRequest { - readonly type: "scratch"; readonly requestId: number; readonly moduleUrl: string; + readonly type: "scratch" | "prepare"; readonly requestId: number; readonly moduleUrl: string; readonly document: Uint8Array; readonly options: BootOptions; } type ScratchFailure = { readonly name: string; readonly message: string } & ( @@ -16,7 +16,7 @@ type ScratchFailure = { readonly name: string; readonly message: string } & ( ); export type ScratchBootReply = | { readonly type: "worker-ready" } - | { readonly type: "scratch-result"; readonly requestId: number; readonly ok: true; readonly shape: SessionShape } + | { readonly type: "scratch-result"; readonly requestId: number; readonly ok: true; readonly shape: SessionShape; readonly module?: WebAssembly.Module } | { readonly type: "scratch-result"; readonly requestId: number; readonly ok: false; readonly error: ScratchFailure }; /** Only the Worker operations used by a one-shot scratch boot. */ @@ -29,7 +29,7 @@ export interface ScratchWorker { export type ScratchWorkerFactory = (url: URL, options: { readonly type: "module" }) => ScratchWorker; /** Boot once on a bounded module Worker, terminating before either outcome becomes observable. */ -export async function scratchBootWithWorker(options: { +export interface ScratchBootWorkerOptions { readonly document: Uint8Array; readonly options: BootOptions; readonly moduleUrl: string | URL; @@ -37,7 +37,27 @@ export async function scratchBootWithWorker(options: { readonly createWorker?: ScratchWorkerFactory; readonly requestDeadlineMs?: number; readonly signal?: AbortSignal; -}): Promise { +} + +export interface PreparedBrowserSession { + readonly shape: SessionShape; + readonly module: WebAssembly.Module; +} + +export async function scratchBootWithWorker(options: ScratchBootWorkerOptions): Promise { + return (await runScratchWorker(options, "scratch")).shape; +} + +export async function prepareBrowserSessionWithWorker(options: ScratchBootWorkerOptions): Promise { + const result = await runScratchWorker(options, "prepare"); + if (!(result.module instanceof WebAssembly.Module)) throw new BrowserBootError("scratch-load", "Preparation Worker did not return a compiled module"); + return { shape: result.shape, module: result.module }; +} + +async function runScratchWorker(options: ScratchBootWorkerOptions, mode: "scratch" | "prepare"): Promise<{ shape: SessionShape; module?: WebAssembly.Module }> { + const document = new Uint8Array(options.document); + const bootOptions = { ...options.options, ...(typeof options.options.console === "object" ? { console: { ...options.options.console } } : {}) }; + const moduleUrl = String(options.moduleUrl); options.signal?.throwIfAborted(); const deadline = options.requestDeadlineMs ?? 5_000; if (!Number.isFinite(deadline) || deadline <= 0 || deadline > 2_147_483_647) { @@ -54,11 +74,11 @@ export async function scratchBootWithWorker(options: { worker = new Worker(new URL("./scratch-worker.js", import.meta.url), { type: "module" }); } } catch (error) { throw new BrowserBootError("scratch-start", "Scratch module Worker could not start", error); } - return new Promise((resolve, reject) => { + return new Promise<{ shape: SessionShape; module?: WebAssembly.Module }>((resolve, reject) => { let settled = false; let requested = false; let timer: ReturnType; - const finish = (error: unknown, shape?: SessionShape) => { + const finish = (error: unknown, result?: { shape: SessionShape; module?: WebAssembly.Module }) => { if (settled) return; settled = true; clearTimeout(timer); @@ -67,7 +87,7 @@ export async function scratchBootWithWorker(options: { worker.removeEventListener("error", failure); worker.removeEventListener("messageerror", decodeFailure); worker.terminate(); - if (shape !== undefined) resolve(shape); else reject(error); + if (result !== undefined) resolve(result); else reject(error); }; const arm = () => { clearTimeout(timer); @@ -84,11 +104,11 @@ export async function scratchBootWithWorker(options: { if (options.signal?.aborted) { abort(); return; } requested = true; arm(); - try { worker.postMessage({ type: "scratch", requestId: 1, moduleUrl: String(options.moduleUrl), document: options.document, options: options.options }); } + try { worker.postMessage({ type: mode, requestId: 1, moduleUrl, document, options: bootOptions }); } catch (error) { finish(error); } } else if (reply.type === "scratch-result" && requested && reply.requestId === 1) { if (options.signal?.aborted) { abort(); return; } - if (reply.ok) finish(undefined, reply.shape); + if (reply.ok) finish(undefined, reply); else { const failure = reply.error; const error = failure.kind === "engine" diff --git a/sdk/src/browser/shipped-host.d.ts b/sdk/src/browser/shipped-host.d.ts index ab1722cf5..334ee1ba7 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -659,6 +659,7 @@ export interface CreateMisoAudioWorkletHostOptions { document: Uint8Array; options: MisoWebBootOptions; simd128ModuleUrl: string; + preparedModule?: WebAssembly.Module; workletModuleUrl: string; } diff --git a/sdk/src/core/asset.ts b/sdk/src/core/asset.ts index 09b5a1952..32af69461 100644 --- a/sdk/src/core/asset.ts +++ b/sdk/src/core/asset.ts @@ -93,6 +93,9 @@ export class MisoEngineAsset { return new MisoEngineAsset(module, digest, 1); } + /** The exact compiled module, strongly owned independently of any instance. */ + get module(): WebAssembly.Module { return this.#module; } + /** How many times `WebAssembly.compile` ran for this asset. Always 1; asserted, not trusted. */ get compileCount(): number { return this.#compiles; diff --git a/sdk/test/browser-defaults-evals.mjs b/sdk/test/browser-defaults-evals.mjs index cbffbaa6c..81aba6850 100644 --- a/sdk/test/browser-defaults-evals.mjs +++ b/sdk/test/browser-defaults-evals.mjs @@ -181,3 +181,161 @@ test("actual scratch entry and client retain real Wasm refusal and usage error t globalThis.fetch = oldFetch; } }); + +import { prepareBrowserSessionWithWorker, prepareBrowserSessionInWorker } from "../src/browser/index.ts"; +import { sessionDocument, effectEntry, ramp } from "./support.mjs"; +import { WasmBoundary } from "../src/core/boundary.ts"; +import { CATALOG } from "../src/generated/catalog.ts"; + +test("prepared worker snapshots inputs and retains the exact module after termination", async () => { + const worker = new FakeWorker(); const document = new Uint8Array([4]); const options = { console: { meterBlocks: 2 } }; + const module = await WebAssembly.compile(new Uint8Array([0,97,115,109,1,0,0,0])); + const pending = prepareBrowserSessionWithWorker({ document, options, moduleUrl: "wasm", createWorker: () => worker }); + document[0] = 9; options.console.meterBlocks = 99; + worker.emit("message", { type: "worker-ready" }); + assert.equal(worker.requests[0].type, "prepare"); assert.equal(worker.requests[0].document[0], 4); + assert.equal(worker.requests[0].options.console.meterBlocks, 2); + worker.emit("message", { ...result, module }); + assert.equal((await pending).module, module); worker.assertClosed(); +}); + +for (const fault of ["post", "missing-module", "abort-before-reply", "reply-before-abort", "messageerror"]) { + test(`prepared worker lifecycle ${fault}`, async () => { + const worker = new FakeWorker(); const controller = new AbortController(); + const module = await WebAssembly.compile(new Uint8Array([0,97,115,109,1,0,0,0])); + const pending = prepareBrowserSessionWithWorker({ document: new Uint8Array(), options: {}, moduleUrl: "wasm", createWorker: () => worker, signal: controller.signal }); + if (fault === "post") worker.onPost = () => { throw new DOMException("clone", "DataCloneError"); }; + worker.emit("message", { type: "worker-ready" }); + if (fault === "abort-before-reply") controller.abort(); + if (fault === "messageerror") worker.emit("messageerror", {}); + worker.emit("message", fault === "missing-module" ? result : { ...result, module }); + if (fault === "reply-before-abort") { controller.abort(); assert.equal((await pending).module, module); } + else await assert.rejects(pending); + worker.emitHistorical("message", { ...result, module }); worker.assertClosed(); + }); +} + +test("createEngine snapshots document and nested policy before scratch awaits and forwards prepared identity", async () => { + const document = new Uint8Array([3]); const policy = { console: { meterBlocks: 2 } }; + const module = await WebAssembly.compile(new Uint8Array([0,97,115,109,1,0,0,0])); + const engine = await createEngine({ document, policy, preparedModule: module, createContext: context, + scratchBoot: async request => { document[0] = 8; policy.console.meterBlocks = 9; assert.equal(request.document[0], 3); return shape; }, + createHost: async request => { assert.equal(request.document[0], 3); assert.equal(request.options.console.meterBlocks, 2); assert.equal(request.preparedModule, module); return { async dispose() {} }; }, + }); + await engine.close(); +}); + +test("preparation compiles once, disposes scratch, and yields fresh stateful live DSP and meter origins", async () => { + const effects = ["miso.parametric-eq", "miso.compressor", "miso.delay"].map((id, index) => { + const row = CATALOG.effects.find(effect => effect.id === id); + const values = id === "miso.parametric-eq" ? { 1: 1, 3: 1000, 4: 6 } + : id === "miso.delay" ? { 1: 5, 2: 0.5, 4: 0.5 } : {}; + return effectEntry(`fx${index}`, id, row.parameters.map(parameter => ({ id: parameter.id, unit: parameter.unitName, value: values[parameter.id] ?? parameter.default }))); + }); + const bytes = await moduleBytes(); + const document = new TextEncoder().encode(sessionDocument({ effects: { simd1: effects }, frames: 16384 })); + const options = { console: { commandQueueRecords: 64, meterBlocks: 2, observationTaps: 1 } }; + let compiles = 0, disposals = 0; const compile = WebAssembly.compile; const dispose = WasmBoundary.prototype.dispose; + WebAssembly.compile = async (...args) => { compiles++; return compile(...args); }; + WasmBoundary.prototype.dispose = function () { disposals++; return dispose.call(this); }; + let prepared; + try { prepared = await prepareBrowserSessionInWorker({ moduleBytes: bytes, document, options }); } + finally { WebAssembly.compile = compile; WasmBoundary.prototype.dispose = dispose; } + assert.equal(compiles, 1); assert.equal(disposals, 1); + const referenceModule = await compile(bytes); + const boot = module => WasmBoundary.boot({ instantiate: () => WebAssembly.instantiate(module, {}) }, document, options); + const live = await boot(prepared.module), reference = await boot(referenceModule); + try { + assert.equal(live.renderedQuanta(), 0n); assert.deepEqual(live.shape(), prepared.shape); + live.meterLease(true); reference.meterLease(true); + let meterWindows = 0, delayedEnergy = 0; + for (let block = 0; block < 8; block++) { + // Excite the effects once, then observe their history while the source is silent. + const pcm = block === 0 ? ramp(128, 1) : new Float32Array(128); + for (const boundary of [live, reference]) assert.equal(boundary.submitSource({ sourceId: "s", generation: 1n, startFrame: BigInt(block * 128), planes: [pcm, pcm], endOfRegion: false }).ok, true); + const output = live.render(128); + assert.deepEqual(output, reference.render(128)); + if (block === 0) assert.notDeepEqual(output.left, pcm, "active processing changes unaffected input"); + if (block >= 2) for (const value of output.left) delayedEnergy += value * value; + const meter = live.pollMeters(); + assert.deepEqual(meter, reference.pollMeters()); + if (meter !== undefined) { + assert.equal(meter.firstSample, BigInt(meterWindows * 256)); + assert.equal(meter.endSample, BigInt((meterWindows + 1) * 256)); + meterWindows++; + } + } + assert.equal(meterWindows, 4, "live meter windows originate at sample zero"); + assert.ok(delayedEnergy > 0.001, "short wet feedback delay produces a nontrivial tail within eight quanta"); + } finally { live.dispose(); reference.dispose(); } +}); + +for (const frames of [0, 1, 129]) for (const channels of [1, 2]) { + test(`preparation handles ${frames} frames and ${channels} channels without console`, async () => { + const pending = prepareBrowserSessionInWorker({ moduleBytes: await moduleBytes(), document: new TextEncoder().encode(sessionDocument({ frames, channels })), options: {} }); + if (frames === 0) { await assert.rejects(pending, error => error instanceof MisoEngineError && error.diagnosticCode === "capacity.zero"); return; } + const prepared = await pending; + assert.equal(prepared.shape.sources[0].frames, BigInt(frames)); + }); +} + +test("current shipped host sends prepared module to worklet without fetch or compile", async () => { + const { createMisoAudioWorkletHost } = await import("../../hosts/host-web/web/miso-engine-v1-audio-worklet-host.js"); + const module = await WebAssembly.compile(new Uint8Array([0,97,115,109,1,0,0,0])); + const previous = { fetch: globalThis.fetch, compile: WebAssembly.compile, node: globalThis.AudioWorkletNode }; + let sent = false, disconnected = false; + globalThis.fetch = async () => { throw new Error("unexpected fetch"); }; + WebAssembly.compile = async () => { throw new Error("unexpected compile"); }; + globalThis.AudioWorkletNode = class { + constructor(_context, _name, options) { + assert.equal(options.processorOptions.module, module); sent = true; + this.port = { close() {}, onmessage: null }; + queueMicrotask(() => this.port.onmessage({ data: { tag: "miso.error.v1", requestId: 0, result: 1 } })); + } + disconnect() { disconnected = true; } + }; + const options = { context: context(), document: new Uint8Array([1]), options: toWebBootOptions({}), simd128ModuleUrl: "must-not-fetch", workletModuleUrl: "worklet", preparedModule: module }; + try { + await assert.rejects(createMisoAudioWorkletHost(options), error => error.tag === "miso.error.v1" && error.result === 1); + assert.equal(sent, true); assert.equal(disconnected, true); + sent = false; + await assert.rejects(createMisoAudioWorkletHost({ ...options, unexpected: true })); assert.equal(sent, false); + await assert.rejects(createMisoAudioWorkletHost({ ...options, preparedModule: {} })); assert.equal(sent, false); + } finally { globalThis.fetch = previous.fetch; WebAssembly.compile = previous.compile; globalThis.AudioWorkletNode = previous.node; } +}); + +for (const cloneFault of [false, true]) test(`actual prepared worker structured clone, clone fault=${cloneFault}`, async () => { + const bytes = await moduleBytes(); const previous = { self: globalThis.self, fetch: globalThis.fetch }; + const worker = new FakeWorker(); let sentModule; + const scope = { onmessage: null, postMessage(reply) { + if (reply.module) { sentModule = reply.module; if (cloneFault) throw new DOMException("module clone failed", "DataCloneError"); } + worker.emit("message", structuredClone(reply)); + } }; + globalThis.self = scope; globalThis.fetch = async () => new Response(bytes); + try { + await import(`../src/browser/scratch-worker.ts?prepare=${cloneFault}`); + worker.onPost = request => scope.onmessage({ data: structuredClone(request) }); + const pending = prepareBrowserSessionWithWorker({ document: new TextEncoder().encode(sessionDocument()), options: {}, moduleUrl: "wasm", createWorker: () => worker }); + worker.emit("message", { type: "worker-ready" }); + if (cloneFault) await assert.rejects(pending, error => error.name === "DataCloneError"); + else { const prepared = await pending; assert.ok(prepared.module instanceof WebAssembly.Module); assert.notEqual(prepared.module, sentModule); await WebAssembly.instantiate(prepared.module, {}); } + worker.assertClosed(); + } finally { globalThis.self = previous.self; globalThis.fetch = previous.fetch; } +}); + + +test("preparation admits several independent mono and stereo sources", async () => { + const document = JSON.parse(sessionDocument({ frames: 257 })); + const source = document.sources[0], track = document.tracks[0], route = document.routes[0]; + document.sources = []; document.tracks = []; document.routes = []; + for (const [index, channels] of [1, 2, 1, 2].entries()) { + const sourceId = `s${index}`, trackId = `t${index}`; + document.sources.push({ ...source, id: sourceId, channels, frames: String(257 + index) }); + document.tracks.push({ ...track, id: trackId, source_id: sourceId, right_source_channel: channels - 1 }); + document.routes.push({ ...route, id: `route${index}`, source: { ...route.source, track_id: trackId } }); + } + const prepared = await prepareBrowserSessionInWorker({ moduleBytes: await moduleBytes(), document: new TextEncoder().encode(JSON.stringify(document)), options: { sourceRingFrames: 256 } }); + assert.deepEqual(prepared.shape.sources.map(source => [source.id, source.channels, source.frames]), [ + ["s0", 1, 257n], ["s1", 2, 258n], ["s2", 1, 259n], ["s3", 2, 260n], + ]); +}); diff --git a/sdk/test/browser-pcm-evals.mjs b/sdk/test/browser-pcm-evals.mjs index 3d49e04cc..ff853d54f 100644 --- a/sdk/test/browser-pcm-evals.mjs +++ b/sdk/test/browser-pcm-evals.mjs @@ -478,7 +478,7 @@ test("control preparation never discards or applies a superseding producer gener assert.deepEqual(raced.seeks, [[2n, 12n], [3n, 16n]]); }); -function runPrelude(source, { tracking = false, mutate = false } = {}) { +function runPrelude(source, { tracking = false, mutate = false, capacity = 2, initial = true } = {}) { const mutated = mutate ? source.replace( "const staging = this.sourcePcm", "if (control[CONTROL_WROTE] > 1) new Float32Array(4); const staging = this.sourcePcm", @@ -490,19 +490,20 @@ function runPrelude(source, { tracking = false, mutate = false } = {}) { 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; } }; + 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 typeof submitResult === "function" ? submitResult() : 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 rings = [1, 2, 1].map((channels, index) => createMsb1Ring({ sourceId: `source-${index}`, channels, frameCapacity: 4, capacity })); 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); + if (!initial) continue; const planes = writer.reserve(3); planes[0].set([index + 1, 2, 3]); if (writer.channels === 2) planes[1].set([4, 5, 6]); @@ -513,6 +514,36 @@ function runPrelude(source, { tracking = false, mutate = false } = {}) { return { sandbox, allocations, engine, attach, rings, ringControls, writers, submissions, seeks, process, setSubmitResult: (value) => { submitResult = value; }, setSeekResult: (value) => { seekResult = value; } }; } +test("full shared runways fill each internal queue gradually within the per-source callback budget", 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, { capacity: 8, initial: false }); + const queued = [0, 0, 0]; const accepted = [0, 0, 0]; const positions = [0n, 0n, 0n]; + let pressure = 0; + run.setSubmitResult(() => { + const index = new Uint8Array(run.engine.memoryBuffer, 0, 8)[7] - 48; + if (queued[index] === 8) { pressure++; return 6; } + queued[index]++; accepted[index]++; return 0; + }); + const refill = () => run.writers.forEach((writer, index) => { + while (writer.occupancy < writer.capacity) { + for (const plane of writer.reserve(4)) plane.fill(index + 0.25); + writer.commit({ generation: 1n, startFrame: positions[index], frames: 4, endOfRegion: false }); + positions[index] += 4n; + } + }); + for (let block = 0; block < 12; block++) { + refill(); const before = [...accepted]; run.process(); + assert.ok(accepted.every((count, index) => count - before[index] <= 2)); + assert.ok(queued.every((count) => count > 0), "each source supplies the upcoming render"); + for (let index = 0; index < queued.length; index++) queued[index]--; + assert.deepEqual(run.ringControls.map((control) => control[MSB1_CONTROL.DEPTH]), queued); + } + assert.deepEqual(queued, [7, 7, 7], "the original internal capacity remains reachable"); + assert.ok(pressure > 0, "real capacity backpressure retains pending shared PCM"); + assert.ok(run.writers.every((writer) => writer.occupancy > 0)); + assert.ok(run.ringControls.every((control) => control[MSB1_CONTROL.UNDERRUNS] === 0 && control[MSB1_CONTROL.REFUSED] === 0)); +}); + 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 });