From 2153193addb4b9de29bb987a43bf2f5f1a0c5acb Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 10 Sep 2026 11:08:28 +0000 Subject: [PATCH 1/7] docs: scope bounded browser startup preparation --- ...-prepare-browser-dsp-and-bound-pcm-feed.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/ISSUE_SPECS/719-prepare-browser-dsp-and-bound-pcm-feed.md 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 00000000..0a7858a9 --- /dev/null +++ b/.github/ISSUE_SPECS/719-prepare-browser-dsp-and-bound-pcm-feed.md @@ -0,0 +1,32 @@ +# 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. From b69a01ab67e35be9d4bf2b4da88e252646e19245 Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 10 Sep 2026 11:11:11 +0000 Subject: [PATCH 2/7] fix(sdk): bound per-source PCM submissions per render --- ...-prepare-browser-dsp-and-bound-pcm-feed.md | 2 + .../miso-engine-v1-pcm-feed-worklet.js | 7 +++- sdk/test/browser-pcm-evals.mjs | 37 +++++++++++++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) 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 index 0a7858a9..6b69d547 100644 --- 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 @@ -30,3 +30,5 @@ A fresh Astra xhigh reviewer verifies the spec before implementation and each co 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. 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 13086300..f103dd6d 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/test/browser-pcm-evals.mjs b/sdk/test/browser-pcm-evals.mjs index 3d49e04c..ff853d54 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 }); From f393c54959fc27e28a204affbfba5e65c5fb0607 Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 10 Sep 2026 11:17:29 +0000 Subject: [PATCH 3/7] feat(sdk): prepare browser DSP and retain compiled module --- .../miso-engine-v1-audio-worklet-host.d.ts | 1 + .../web/miso-engine-v1-audio-worklet-host.js | 5 +- sdk/src/browser/default-host.ts | 2 + sdk/src/browser/engine.ts | 51 ++++++- sdk/src/browser/index.ts | 4 +- sdk/src/browser/scratch-worker.ts | 22 ++- sdk/src/browser/scratch.ts | 38 ++++-- sdk/src/browser/shipped-host.d.ts | 1 + sdk/src/core/asset.ts | 3 + sdk/test/browser-defaults-evals.mjs | 126 ++++++++++++++++++ 10 files changed, 228 insertions(+), 25 deletions(-) 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 ab1722cf..334ee1ba 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 cc8ac902..1aec106f 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/src/browser/default-host.ts b/sdk/src/browser/default-host.ts index 12a50d73..925b0e45 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 ea2e0923..945ceacc 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 a5b4a961..d72332fc 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 a5be0a2f..6d65ab6e 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 74bb52a6..22edcb26 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 ab1722cf..334ee1ba 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 09b5a195..32af6946 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 cbffbaa6..032617d0 100644 --- a/sdk/test/browser-defaults-evals.mjs +++ b/sdk/test/browser-defaults-evals.mjs @@ -181,3 +181,129 @@ 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); + return effectEntry(`fx${index}`, id, row.parameters.map(parameter => ({ id: parameter.id, unit: parameter.unitName, value: 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); + for (let block = 0; block < 8; block++) { + const pcm = ramp(128, block + 1); + 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); + assert.deepEqual(live.render(128), reference.render(128)); + assert.deepEqual(live.pollMeters(), reference.pollMeters()); + } + } 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; } +}); From e796877d43dfc047f08290039f2508e7741fb59e Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 10 Sep 2026 11:19:31 +0000 Subject: [PATCH 4/7] docs: record prepared DSP checkpoint evidence --- .../ISSUE_SPECS/719-prepare-browser-dsp-and-bound-pcm-feed.md | 2 ++ 1 file changed, 2 insertions(+) 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 index 6b69d547..ba629770 100644 --- 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 @@ -32,3 +32,5 @@ Spec prepared before implementation. The existing adapter opening-attachment can 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. From f011e36377cd4b93a02fefefae85eb1867248f7f Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 10 Sep 2026 11:22:11 +0000 Subject: [PATCH 5/7] test(sdk): exercise active DSP state and mixed source rehearsal --- sdk/test/browser-defaults-evals.mjs | 40 ++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/sdk/test/browser-defaults-evals.mjs b/sdk/test/browser-defaults-evals.mjs index 032617d0..81aba685 100644 --- a/sdk/test/browser-defaults-evals.mjs +++ b/sdk/test/browser-defaults-evals.mjs @@ -228,7 +228,9 @@ test("createEngine snapshots document and nested policy before scratch awaits an 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); - return effectEntry(`fx${index}`, id, row.parameters.map(parameter => ({ id: parameter.id, unit: parameter.unitName, value: parameter.default }))); + 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 })); @@ -246,12 +248,25 @@ test("preparation compiles once, disposes scratch, and yields fresh stateful liv 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++) { - const pcm = ramp(128, block + 1); + // 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); - assert.deepEqual(live.render(128), reference.render(128)); - assert.deepEqual(live.pollMeters(), reference.pollMeters()); + 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(); } }); @@ -307,3 +322,20 @@ for (const cloneFault of [false, true]) test(`actual prepared worker structured 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], + ]); +}); From cf7e695b29043abcae9f92d5172b719e33764f03 Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 10 Sep 2026 11:23:33 +0000 Subject: [PATCH 6/7] chore(sdk): prepare 0.2.2 startup release qualification --- .../719-prepare-browser-dsp-and-bound-pcm-feed.md | 4 ++++ .github/workflows/npm-publish.yml | 12 ++++++------ sdk/README.md | 14 ++++++++++++++ sdk/package-lock.json | 4 ++-- sdk/package.json | 2 +- 5 files changed, 27 insertions(+), 9 deletions(-) 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 index ba629770..18eb402b 100644 --- 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 @@ -34,3 +34,7 @@ Fresh Astra xhigh spec review approves beginning the bounded tranche. Clarificat 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. diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 5b1ef795..d9a5ea1d 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/sdk/README.md b/sdk/README.md index dbe29898..f49343e6 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 5aaf12ea..96cc468a 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 a70552d2..6e58ce5a 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", From 28b16ac3f2e4061067c8a0ff2ef4e791cbf770eb Mon Sep 17 00:00:00 2001 From: BL Date: Thu, 10 Sep 2026 11:47:55 +0000 Subject: [PATCH 7/7] docs: record exact 64-track startup and sustained qualification --- .../719-prepare-browser-dsp-and-bound-pcm-feed.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index 18eb402b..a01ae49f 100644 --- 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 @@ -38,3 +38,15 @@ Prepared-module checkpoint f393c549 adds the explicit retained module API, bound 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.