@viji-dev/core 0.4.6 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ class VijiCoreError extends Error {
6
6
  this.name = "VijiCoreError";
7
7
  }
8
8
  }
9
- const inlineScriptCode = '"use strict";\n(() => {\n // src/iframe/protocol.ts\n var PROTOCOL_VERSION = 1;\n function collectTransferables(msg) {\n switch (msg.type) {\n case "init": {\n const m = msg;\n const out = [];\n if (m.data?.canvas) out.push(m.data.canvas);\n const wasm = m.data?.cvWasmFiles;\n if (wasm) {\n out.push(\n wasm.simdLoaderJs,\n wasm.simdBinary,\n wasm.nosimdLoaderJs,\n wasm.nosimdBinary\n );\n }\n return out;\n }\n case "video-canvas-setup": {\n const m = msg;\n return m.data?.offscreenCanvas ? [m.data.offscreenCanvas] : [];\n }\n case "video-frame-update": {\n const m = msg;\n return m.data?.imageBitmap ? [m.data.imageBitmap] : [];\n }\n case "video-frame-direct": {\n const m = msg;\n return m.data?.imageBitmap ? [m.data.imageBitmap] : [];\n }\n case "parameter-update": {\n const m = msg;\n return m.data?.transferList ?? [];\n }\n case "parameter-batch-update": {\n const m = msg;\n return m.data?.transferList ?? [];\n }\n case "audio-analysis-update": {\n const m = msg;\n const out = [];\n if (m.data?.frequencyData?.buffer) out.push(m.data.frequencyData.buffer);\n if (m.data?.waveformData?.buffer) out.push(m.data.waveformData.buffer);\n return out;\n }\n case "capture-frame-result":\n case "auto-capture-result": {\n const data = msg.data;\n return data?.bitmap ? [data.bitmap] : [];\n }\n default:\n return [];\n }\n }\n\n // src/iframe/inline-script.ts\n var hostOrigin = null;\n var hostWindow = null;\n var worker = null;\n var workerBlobUrl = null;\n var canvas = null;\n var domHandlersAttached = false;\n var sensorHandlersAttached = false;\n function whenDomReady() {\n if (document.readyState === "loading") {\n return new Promise((resolve) => {\n document.addEventListener("DOMContentLoaded", () => resolve(), {\n once: true\n });\n });\n }\n return Promise.resolve();\n }\n async function announceReady() {\n await whenDomReady();\n const msg = {\n type: "iframe-ready",\n protocolVersion: PROTOCOL_VERSION,\n environment: {\n crossOriginIsolated: window.crossOriginIsolated,\n isSecureContext: window.isSecureContext,\n origin: self.origin\n }\n };\n window.parent.postMessage(msg, "*");\n }\n function installHostListener() {\n window.addEventListener("message", (event) => {\n if (event.source !== window.parent) return;\n if (hostOrigin === null) {\n hostOrigin = event.origin;\n hostWindow = window.parent;\n } else if (event.origin !== hostOrigin) {\n return;\n }\n handleHostMessage(event.data).catch((err) => {\n reportError(err);\n });\n });\n }\n function postToHost(msg, transfer = []) {\n if (hostOrigin === null || hostWindow === null) {\n return;\n }\n const targetOrigin = hostOrigin === "null" ? "*" : hostOrigin;\n hostWindow.postMessage(msg, targetOrigin, transfer);\n }\n function reportError(err) {\n const e = err;\n const msg = e?.stack ? {\n type: "iframe-error",\n message: e.message ?? String(err),\n stack: e.stack\n } : {\n type: "iframe-error",\n message: e?.message ?? String(err)\n };\n emitToHost(msg);\n }\n function reportWorkerOnError(e) {\n const msg = {\n type: "iframe-error",\n message: e.message ? `Worker error: ${e.message}` : "Worker error: (no detail \\u2014 likely cross-origin opacity)",\n ...e.error && e.error.stack ? { stack: e.error.stack } : {},\n ...e.filename ? { filename: e.filename } : {},\n ...e.lineno ? { lineno: e.lineno } : {},\n ...e.colno ? { colno: e.colno } : {}\n };\n emitToHost(msg);\n }\n function emitToHost(msg) {\n if (hostOrigin && hostWindow) {\n const targetOrigin = hostOrigin === "null" ? "*" : hostOrigin;\n hostWindow.postMessage(msg, targetOrigin);\n } else {\n window.parent.postMessage(msg, "*");\n }\n }\n async function handleHostMessage(msg) {\n switch (msg.type) {\n case "viji-bootstrap":\n await handleBootstrap(msg);\n return;\n case "viji-host-message":\n relayHostToWorker(msg.payload);\n return;\n case "viji-resize":\n handleResize(msg.width, msg.height);\n return;\n case "viji-enable-sensors":\n setSensorsEnabled(msg.enabled);\n return;\n case "viji-enable-interaction":\n setInteractionEnabled(msg.enabled);\n return;\n case "viji-terminate":\n handleTerminate();\n return;\n }\n }\n async function handleBootstrap(msg) {\n if (worker) {\n return;\n }\n canvas = document.createElement("canvas");\n canvas.id = "viji-canvas";\n canvas.width = msg.init.canvasWidth;\n canvas.height = msg.init.canvasHeight;\n canvas.style.width = "100%";\n canvas.style.height = "100%";\n canvas.style.display = "block";\n canvas.tabIndex = 0;\n canvas.style.outline = "none";\n document.body.appendChild(canvas);\n const offscreen = canvas.transferControlToOffscreen();\n workerBlobUrl = URL.createObjectURL(\n new Blob([msg.workerCode], { type: "application/javascript" })\n );\n worker = new Worker(workerBlobUrl, { type: "classic" });\n worker.onerror = (e) => {\n reportWorkerOnError(e);\n };\n worker.onmessage = (event) => {\n relayWorkerToHost(event.data);\n };\n const initId = `init_${Date.now()}`;\n const initMsg = {\n type: "init",\n id: initId,\n timestamp: Date.now(),\n data: {\n canvas: offscreen,\n isHeadless: msg.init.isHeadless,\n sc: msg.init.sc,\n rh: msg.init.rh,\n cvWasmFiles: msg.wasmFiles\n }\n };\n const initResponse = waitForWorkerMessage((m) => {\n return m && m.type === "init-response" && m.id === initId;\n });\n worker.postMessage(initMsg, [\n offscreen,\n msg.wasmFiles.simdLoaderJs,\n msg.wasmFiles.simdBinary,\n msg.wasmFiles.nosimdLoaderJs,\n msg.wasmFiles.nosimdBinary\n ]);\n await initResponse;\n if (msg.init.allowInteraction && !msg.init.isHeadless) {\n setInteractionEnabled(true);\n }\n if (msg.init.allowSensors && !msg.init.isHeadless) {\n setSensorsEnabled(true);\n }\n if (canvas) {\n canvas.addEventListener("contextmenu", (e) => e.preventDefault());\n }\n postToHost({ type: "viji-ready" });\n }\n function waitForWorkerMessage(predicate) {\n return new Promise((resolve, reject) => {\n if (!worker) {\n reject(new Error("worker not spawned"));\n return;\n }\n const w = worker;\n const onceListener = (event) => {\n if (predicate(event.data)) {\n w.removeEventListener("message", onceListener);\n resolve(event.data);\n }\n };\n w.addEventListener("message", onceListener);\n });\n }\n function relayHostToWorker(payload) {\n if (!worker || !payload) return;\n try {\n const transfer = collectTransferables(payload);\n worker.postMessage(payload, transfer);\n } catch (err) {\n reportError(err);\n }\n }\n function relayWorkerToHost(payload) {\n if (!payload) return;\n try {\n const transfer = collectTransferables(payload);\n postToHost(\n { type: "worker-message", payload },\n transfer\n );\n } catch (err) {\n reportError(err);\n }\n }\n function handleResize(width, height) {\n if (!worker) return;\n worker.postMessage({\n type: "resolution-update",\n id: `resize_${Date.now()}`,\n timestamp: Date.now(),\n data: {\n effectiveWidth: width,\n effectiveHeight: height,\n displayScale: 1\n }\n });\n }\n var isMouseInCanvas = false;\n function setInteractionEnabled(enabled) {\n if (enabled === domHandlersAttached) return;\n if (!canvas) return;\n if (enabled) {\n canvas.addEventListener("mousedown", handleMouseEvent, { passive: false });\n canvas.addEventListener("mousemove", handleMouseEvent, { passive: false });\n canvas.addEventListener("mouseup", handleMouseEvent, { passive: false });\n canvas.addEventListener("mouseenter", handleMouseEnter, { passive: false });\n canvas.addEventListener("mouseleave", handleMouseLeave, { passive: false });\n canvas.addEventListener("wheel", handleWheelEvent, { passive: false });\n canvas.addEventListener("touchstart", handleTouchEvent, { passive: false });\n canvas.addEventListener("touchmove", handleTouchEvent, { passive: false });\n canvas.addEventListener("touchend", handleTouchEvent, { passive: false });\n canvas.addEventListener("touchcancel", handleTouchEvent, { passive: false });\n document.addEventListener("keydown", handleKeyboardEvent, { passive: false });\n document.addEventListener("keyup", handleKeyboardEvent, { passive: false });\n canvas.addEventListener("mousedown", focusCanvas);\n canvas.addEventListener("touchstart", focusCanvas);\n domHandlersAttached = true;\n } else {\n canvas.removeEventListener("mousedown", handleMouseEvent);\n canvas.removeEventListener("mousemove", handleMouseEvent);\n canvas.removeEventListener("mouseup", handleMouseEvent);\n canvas.removeEventListener("mouseenter", handleMouseEnter);\n canvas.removeEventListener("mouseleave", handleMouseLeave);\n canvas.removeEventListener("wheel", handleWheelEvent);\n canvas.removeEventListener("touchstart", handleTouchEvent);\n canvas.removeEventListener("touchmove", handleTouchEvent);\n canvas.removeEventListener("touchend", handleTouchEvent);\n canvas.removeEventListener("touchcancel", handleTouchEvent);\n document.removeEventListener("keydown", handleKeyboardEvent);\n document.removeEventListener("keyup", handleKeyboardEvent);\n canvas.removeEventListener("mousedown", focusCanvas);\n canvas.removeEventListener("touchstart", focusCanvas);\n domHandlersAttached = false;\n }\n }\n function focusCanvas() {\n if (canvas) canvas.focus();\n }\n function dualRoute(workerType, data) {\n if (worker) {\n worker.postMessage({\n type: workerType,\n id: `${workerType}_${Date.now()}`,\n timestamp: Date.now(),\n data\n });\n }\n postToHost({\n type: "interaction-event",\n kind: workerType,\n data\n });\n }\n function handleMouseEvent(event) {\n if (!canvas) return;\n event.preventDefault();\n const rect = canvas.getBoundingClientRect();\n const x = (event.clientX - rect.left) * (canvas.width / rect.width);\n const y = (event.clientY - rect.top) * (canvas.height / rect.height);\n dualRoute("mouse-update", {\n x,\n y,\n buttons: event.buttons,\n deltaX: event.movementX || 0,\n deltaY: event.movementY || 0,\n wheelDeltaX: 0,\n wheelDeltaY: 0,\n isInCanvas: isMouseInCanvas,\n timestamp: performance.now()\n });\n }\n function handleMouseEnter(event) {\n isMouseInCanvas = true;\n handleMouseEvent(event);\n }\n function handleMouseLeave(event) {\n isMouseInCanvas = false;\n handleMouseEvent(event);\n }\n function handleWheelEvent(event) {\n if (!canvas) return;\n event.preventDefault();\n const rect = canvas.getBoundingClientRect();\n const x = (event.clientX - rect.left) * (canvas.width / rect.width);\n const y = (event.clientY - rect.top) * (canvas.height / rect.height);\n dualRoute("mouse-update", {\n x,\n y,\n buttons: event.buttons,\n deltaX: 0,\n deltaY: 0,\n wheelDeltaX: event.deltaX,\n wheelDeltaY: event.deltaY,\n isInCanvas: isMouseInCanvas,\n timestamp: performance.now()\n });\n }\n var KEY_PASSTHROUGH = /* @__PURE__ */ new Set([\n "Tab",\n "F1",\n "F2",\n "F3",\n "F4",\n "F5",\n "F11",\n "F12"\n ]);\n function handleKeyboardEvent(event) {\n if (!KEY_PASSTHROUGH.has(event.key)) {\n event.preventDefault();\n }\n dualRoute("keyboard-update", {\n type: event.type,\n key: event.key,\n code: event.code,\n keyCode: event.keyCode,\n shiftKey: event.shiftKey,\n ctrlKey: event.ctrlKey,\n altKey: event.altKey,\n metaKey: event.metaKey,\n timestamp: performance.now()\n });\n }\n function handleTouchEvent(event) {\n if (!canvas) return;\n event.preventDefault();\n const rect = canvas.getBoundingClientRect();\n const scaleX = canvas.width / rect.width;\n const scaleY = canvas.height / rect.height;\n const canvasW = canvas.width;\n const canvasH = canvas.height;\n const mapTouch = (t) => {\n const cx = (t.clientX - rect.left) * scaleX;\n const cy = (t.clientY - rect.top) * scaleY;\n return {\n identifier: t.identifier,\n clientX: cx,\n clientY: cy,\n radiusX: t.radiusX ?? 0,\n radiusY: t.radiusY ?? 0,\n rotationAngle: t.rotationAngle ?? 0,\n force: t.force ?? 0,\n isInCanvas: cx >= 0 && cx <= canvasW && cy >= 0 && cy <= canvasH\n };\n };\n const changedMap = /* @__PURE__ */ new Map();\n for (let i = 0; i < event.changedTouches.length; i++) {\n const t = event.changedTouches[i];\n changedMap.set(t.identifier, mapTouch(t));\n }\n const touches = Array.from(event.touches).map((t) => {\n return changedMap.get(t.identifier) ?? mapTouch(t);\n });\n if (event.type === "touchend" || event.type === "touchcancel") {\n const activeIds = new Set(touches.map((t) => t.identifier));\n for (const [id, mapped] of changedMap) {\n if (!activeIds.has(id)) {\n touches.push({ ...mapped, ended: true });\n }\n }\n }\n dualRoute("touch-update", {\n type: event.type,\n touches,\n timestamp: performance.now()\n });\n }\n function setSensorsEnabled(enabled) {\n if (enabled === sensorHandlersAttached) return;\n if (enabled) {\n window.addEventListener("devicemotion", handleDeviceMotion);\n window.addEventListener("deviceorientation", handleDeviceOrientation);\n sensorHandlersAttached = true;\n } else {\n window.removeEventListener("devicemotion", handleDeviceMotion);\n window.removeEventListener("deviceorientation", handleDeviceOrientation);\n sensorHandlersAttached = false;\n }\n }\n function handleDeviceMotion(event) {\n if (!event.acceleration && !event.accelerationIncludingGravity && !event.rotationRate) {\n return;\n }\n postToHost({\n type: "sensor-event",\n kind: "motion",\n data: {\n acceleration: event.acceleration ? {\n x: event.acceleration.x,\n y: event.acceleration.y,\n z: event.acceleration.z\n } : null,\n accelerationIncludingGravity: event.accelerationIncludingGravity ? {\n x: event.accelerationIncludingGravity.x,\n y: event.accelerationIncludingGravity.y,\n z: event.accelerationIncludingGravity.z\n } : null,\n rotationRate: event.rotationRate ? {\n alpha: event.rotationRate.alpha,\n beta: event.rotationRate.beta,\n gamma: event.rotationRate.gamma\n } : null,\n interval: event.interval || 16\n }\n });\n }\n function handleDeviceOrientation(event) {\n postToHost({\n type: "sensor-event",\n kind: "orientation",\n data: {\n alpha: event.alpha,\n beta: event.beta,\n gamma: event.gamma,\n absolute: event.absolute || false\n }\n });\n }\n function handleTerminate() {\n setInteractionEnabled(false);\n setSensorsEnabled(false);\n if (worker) {\n worker.terminate();\n worker = null;\n }\n if (workerBlobUrl) {\n URL.revokeObjectURL(workerBlobUrl);\n workerBlobUrl = null;\n }\n }\n installHostListener();\n announceReady().catch((err) => reportError(err));\n window.addEventListener("error", (e) => {\n reportError(e.error ?? new Error(e.message));\n });\n window.addEventListener("unhandledrejection", (e) => {\n reportError(e.reason ?? new Error("Unhandled promise rejection"));\n });\n})();\n';
9
+ const inlineScriptCode = '"use strict";\n(() => {\n // src/iframe/protocol.ts\n var PROTOCOL_VERSION = 1;\n function collectTransferables(msg) {\n switch (msg.type) {\n case "init": {\n const m = msg;\n const out = [];\n if (m.data?.canvas) out.push(m.data.canvas);\n const wasm = m.data?.cvWasmFiles;\n if (wasm) {\n out.push(\n wasm.simdLoaderJs,\n wasm.simdBinary,\n wasm.nosimdLoaderJs,\n wasm.nosimdBinary\n );\n }\n if (m.data?.controlPort) out.push(m.data.controlPort);\n return out;\n }\n case "video-canvas-setup": {\n const m = msg;\n return m.data?.offscreenCanvas ? [m.data.offscreenCanvas] : [];\n }\n case "video-frame-update": {\n const m = msg;\n return m.data?.imageBitmap ? [m.data.imageBitmap] : [];\n }\n case "video-frame-direct": {\n const m = msg;\n return m.data?.imageBitmap ? [m.data.imageBitmap] : [];\n }\n case "parameter-update": {\n const m = msg;\n return m.data?.transferList ?? [];\n }\n case "parameter-batch-update": {\n const m = msg;\n return m.data?.transferList ?? [];\n }\n case "audio-analysis-update": {\n const m = msg;\n const out = [];\n if (m.data?.frequencyData?.buffer) out.push(m.data.frequencyData.buffer);\n if (m.data?.waveformData?.buffer) out.push(m.data.waveformData.buffer);\n return out;\n }\n case "capture-frame-result":\n case "auto-capture-result": {\n const data = msg.data;\n return data?.bitmap ? [data.bitmap] : [];\n }\n default:\n return [];\n }\n }\n\n // src/iframe/inline-script.ts\n var hostOrigin = null;\n var hostWindow = null;\n var worker = null;\n var workerBlobUrl = null;\n var canvas = null;\n var domHandlersAttached = false;\n var sensorHandlersAttached = false;\n function whenDomReady() {\n if (document.readyState === "loading") {\n return new Promise((resolve) => {\n document.addEventListener("DOMContentLoaded", () => resolve(), {\n once: true\n });\n });\n }\n return Promise.resolve();\n }\n async function announceReady() {\n await whenDomReady();\n const msg = {\n type: "iframe-ready",\n protocolVersion: PROTOCOL_VERSION,\n environment: {\n crossOriginIsolated: window.crossOriginIsolated,\n isSecureContext: window.isSecureContext,\n origin: self.origin\n }\n };\n window.parent.postMessage(msg, "*");\n }\n function installHostListener() {\n window.addEventListener("message", (event) => {\n if (event.source !== window.parent) return;\n if (hostOrigin === null) {\n hostOrigin = event.origin;\n hostWindow = window.parent;\n } else if (event.origin !== hostOrigin) {\n return;\n }\n handleHostMessage(event.data).catch((err) => {\n reportError(err);\n });\n });\n }\n function postToHost(msg, transfer = []) {\n if (hostOrigin === null || hostWindow === null) {\n return;\n }\n const targetOrigin = hostOrigin === "null" ? "*" : hostOrigin;\n hostWindow.postMessage(msg, targetOrigin, transfer);\n }\n function reportError(err) {\n const e = err;\n const msg = e?.stack ? {\n type: "iframe-error",\n message: e.message ?? String(err),\n stack: e.stack\n } : {\n type: "iframe-error",\n message: e?.message ?? String(err)\n };\n emitToHost(msg);\n }\n function reportWorkerOnError(e) {\n const msg = {\n type: "iframe-error",\n message: e.message ? `Worker error: ${e.message}` : "Worker error: (no detail \\u2014 likely cross-origin opacity)",\n ...e.error && e.error.stack ? { stack: e.error.stack } : {},\n ...e.filename ? { filename: e.filename } : {},\n ...e.lineno ? { lineno: e.lineno } : {},\n ...e.colno ? { colno: e.colno } : {}\n };\n emitToHost(msg);\n }\n function emitToHost(msg) {\n if (hostOrigin && hostWindow) {\n const targetOrigin = hostOrigin === "null" ? "*" : hostOrigin;\n hostWindow.postMessage(msg, targetOrigin);\n } else {\n window.parent.postMessage(msg, "*");\n }\n }\n async function handleHostMessage(msg) {\n switch (msg.type) {\n case "viji-bootstrap":\n await handleBootstrap(msg);\n return;\n case "viji-host-message":\n relayHostToWorker(msg.payload);\n return;\n case "viji-resize":\n handleResize(msg.width, msg.height);\n return;\n case "viji-enable-sensors":\n setSensorsEnabled(msg.enabled);\n return;\n case "viji-enable-interaction":\n setInteractionEnabled(msg.enabled);\n return;\n case "viji-terminate":\n handleTerminate();\n return;\n }\n }\n async function handleBootstrap(msg) {\n if (worker) {\n return;\n }\n canvas = document.createElement("canvas");\n canvas.id = "viji-canvas";\n canvas.width = msg.init.canvasWidth;\n canvas.height = msg.init.canvasHeight;\n canvas.style.width = "100%";\n canvas.style.height = "100%";\n canvas.style.display = "block";\n canvas.tabIndex = 0;\n canvas.style.outline = "none";\n document.body.appendChild(canvas);\n const offscreen = canvas.transferControlToOffscreen();\n workerBlobUrl = URL.createObjectURL(\n new Blob([msg.workerCode], { type: "application/javascript" })\n );\n worker = new Worker(workerBlobUrl, { type: "classic" });\n worker.onerror = (e) => {\n reportWorkerOnError(e);\n };\n worker.onmessage = (event) => {\n relayWorkerToHost(event.data);\n };\n const initId = `init_${Date.now()}`;\n const initMsg = {\n type: "init",\n id: initId,\n timestamp: Date.now(),\n data: {\n canvas: offscreen,\n isHeadless: msg.init.isHeadless,\n sc: msg.init.sc,\n rh: msg.init.rh,\n cvWasmFiles: msg.wasmFiles,\n controlPort: msg.controlPort\n }\n };\n const initResponse = waitForWorkerMessage((m) => {\n return m && m.type === "init-response" && m.id === initId;\n });\n worker.postMessage(initMsg, [\n offscreen,\n msg.wasmFiles.simdLoaderJs,\n msg.wasmFiles.simdBinary,\n msg.wasmFiles.nosimdLoaderJs,\n msg.wasmFiles.nosimdBinary,\n msg.controlPort\n ]);\n await initResponse;\n if (msg.init.allowInteraction && !msg.init.isHeadless) {\n setInteractionEnabled(true);\n }\n if (msg.init.allowSensors && !msg.init.isHeadless) {\n setSensorsEnabled(true);\n }\n if (canvas) {\n canvas.addEventListener("contextmenu", (e) => e.preventDefault());\n }\n postToHost({ type: "viji-ready" });\n }\n function waitForWorkerMessage(predicate) {\n return new Promise((resolve, reject) => {\n if (!worker) {\n reject(new Error("worker not spawned"));\n return;\n }\n const w = worker;\n const onceListener = (event) => {\n if (predicate(event.data)) {\n w.removeEventListener("message", onceListener);\n resolve(event.data);\n }\n };\n w.addEventListener("message", onceListener);\n });\n }\n function relayHostToWorker(payload) {\n if (!worker || !payload) return;\n try {\n const transfer = collectTransferables(payload);\n worker.postMessage(payload, transfer);\n } catch (err) {\n reportError(err);\n }\n }\n function relayWorkerToHost(payload) {\n if (!payload) return;\n try {\n const transfer = collectTransferables(payload);\n postToHost(\n { type: "worker-message", payload },\n transfer\n );\n } catch (err) {\n reportError(err);\n }\n }\n function handleResize(width, height) {\n if (!worker) return;\n worker.postMessage({\n type: "resolution-update",\n id: `resize_${Date.now()}`,\n timestamp: Date.now(),\n data: {\n effectiveWidth: width,\n effectiveHeight: height,\n displayScale: 1\n }\n });\n }\n var isMouseInCanvas = false;\n function setInteractionEnabled(enabled) {\n if (enabled === domHandlersAttached) return;\n if (!canvas) return;\n if (enabled) {\n canvas.addEventListener("mousedown", handleMouseEvent, { passive: false });\n canvas.addEventListener("mousemove", handleMouseEvent, { passive: false });\n canvas.addEventListener("mouseup", handleMouseEvent, { passive: false });\n canvas.addEventListener("mouseenter", handleMouseEnter, { passive: false });\n canvas.addEventListener("mouseleave", handleMouseLeave, { passive: false });\n canvas.addEventListener("wheel", handleWheelEvent, { passive: false });\n canvas.addEventListener("touchstart", handleTouchEvent, { passive: false });\n canvas.addEventListener("touchmove", handleTouchEvent, { passive: false });\n canvas.addEventListener("touchend", handleTouchEvent, { passive: false });\n canvas.addEventListener("touchcancel", handleTouchEvent, { passive: false });\n document.addEventListener("keydown", handleKeyboardEvent, { passive: false });\n document.addEventListener("keyup", handleKeyboardEvent, { passive: false });\n canvas.addEventListener("mousedown", focusCanvas);\n canvas.addEventListener("touchstart", focusCanvas);\n domHandlersAttached = true;\n } else {\n canvas.removeEventListener("mousedown", handleMouseEvent);\n canvas.removeEventListener("mousemove", handleMouseEvent);\n canvas.removeEventListener("mouseup", handleMouseEvent);\n canvas.removeEventListener("mouseenter", handleMouseEnter);\n canvas.removeEventListener("mouseleave", handleMouseLeave);\n canvas.removeEventListener("wheel", handleWheelEvent);\n canvas.removeEventListener("touchstart", handleTouchEvent);\n canvas.removeEventListener("touchmove", handleTouchEvent);\n canvas.removeEventListener("touchend", handleTouchEvent);\n canvas.removeEventListener("touchcancel", handleTouchEvent);\n document.removeEventListener("keydown", handleKeyboardEvent);\n document.removeEventListener("keyup", handleKeyboardEvent);\n canvas.removeEventListener("mousedown", focusCanvas);\n canvas.removeEventListener("touchstart", focusCanvas);\n domHandlersAttached = false;\n }\n }\n function focusCanvas() {\n if (canvas) canvas.focus();\n }\n function dualRoute(workerType, data) {\n if (worker) {\n worker.postMessage({\n type: workerType,\n id: `${workerType}_${Date.now()}`,\n timestamp: Date.now(),\n data\n });\n }\n postToHost({\n type: "interaction-event",\n kind: workerType,\n data\n });\n }\n function handleMouseEvent(event) {\n if (!canvas) return;\n event.preventDefault();\n const rect = canvas.getBoundingClientRect();\n const x = (event.clientX - rect.left) * (canvas.width / rect.width);\n const y = (event.clientY - rect.top) * (canvas.height / rect.height);\n dualRoute("mouse-update", {\n x,\n y,\n buttons: event.buttons,\n deltaX: event.movementX || 0,\n deltaY: event.movementY || 0,\n wheelDeltaX: 0,\n wheelDeltaY: 0,\n isInCanvas: isMouseInCanvas,\n timestamp: performance.now()\n });\n }\n function handleMouseEnter(event) {\n isMouseInCanvas = true;\n handleMouseEvent(event);\n }\n function handleMouseLeave(event) {\n isMouseInCanvas = false;\n handleMouseEvent(event);\n }\n function handleWheelEvent(event) {\n if (!canvas) return;\n event.preventDefault();\n const rect = canvas.getBoundingClientRect();\n const x = (event.clientX - rect.left) * (canvas.width / rect.width);\n const y = (event.clientY - rect.top) * (canvas.height / rect.height);\n dualRoute("mouse-update", {\n x,\n y,\n buttons: event.buttons,\n deltaX: 0,\n deltaY: 0,\n wheelDeltaX: event.deltaX,\n wheelDeltaY: event.deltaY,\n isInCanvas: isMouseInCanvas,\n timestamp: performance.now()\n });\n }\n var KEY_PASSTHROUGH = /* @__PURE__ */ new Set([\n "Tab",\n "F1",\n "F2",\n "F3",\n "F4",\n "F5",\n "F11",\n "F12"\n ]);\n function handleKeyboardEvent(event) {\n if (!KEY_PASSTHROUGH.has(event.key)) {\n event.preventDefault();\n }\n dualRoute("keyboard-update", {\n type: event.type,\n key: event.key,\n code: event.code,\n keyCode: event.keyCode,\n shiftKey: event.shiftKey,\n ctrlKey: event.ctrlKey,\n altKey: event.altKey,\n metaKey: event.metaKey,\n timestamp: performance.now()\n });\n }\n function handleTouchEvent(event) {\n if (!canvas) return;\n event.preventDefault();\n const rect = canvas.getBoundingClientRect();\n const scaleX = canvas.width / rect.width;\n const scaleY = canvas.height / rect.height;\n const canvasW = canvas.width;\n const canvasH = canvas.height;\n const mapTouch = (t) => {\n const cx = (t.clientX - rect.left) * scaleX;\n const cy = (t.clientY - rect.top) * scaleY;\n return {\n identifier: t.identifier,\n clientX: cx,\n clientY: cy,\n radiusX: t.radiusX ?? 0,\n radiusY: t.radiusY ?? 0,\n rotationAngle: t.rotationAngle ?? 0,\n force: t.force ?? 0,\n isInCanvas: cx >= 0 && cx <= canvasW && cy >= 0 && cy <= canvasH\n };\n };\n const changedMap = /* @__PURE__ */ new Map();\n for (let i = 0; i < event.changedTouches.length; i++) {\n const t = event.changedTouches[i];\n changedMap.set(t.identifier, mapTouch(t));\n }\n const touches = Array.from(event.touches).map((t) => {\n return changedMap.get(t.identifier) ?? mapTouch(t);\n });\n if (event.type === "touchend" || event.type === "touchcancel") {\n const activeIds = new Set(touches.map((t) => t.identifier));\n for (const [id, mapped] of changedMap) {\n if (!activeIds.has(id)) {\n touches.push({ ...mapped, ended: true });\n }\n }\n }\n dualRoute("touch-update", {\n type: event.type,\n touches,\n timestamp: performance.now()\n });\n }\n function setSensorsEnabled(enabled) {\n if (enabled === sensorHandlersAttached) return;\n if (enabled) {\n window.addEventListener("devicemotion", handleDeviceMotion);\n window.addEventListener("deviceorientation", handleDeviceOrientation);\n sensorHandlersAttached = true;\n } else {\n window.removeEventListener("devicemotion", handleDeviceMotion);\n window.removeEventListener("deviceorientation", handleDeviceOrientation);\n sensorHandlersAttached = false;\n }\n }\n function handleDeviceMotion(event) {\n if (!event.acceleration && !event.accelerationIncludingGravity && !event.rotationRate) {\n return;\n }\n postToHost({\n type: "sensor-event",\n kind: "motion",\n data: {\n acceleration: event.acceleration ? {\n x: event.acceleration.x,\n y: event.acceleration.y,\n z: event.acceleration.z\n } : null,\n accelerationIncludingGravity: event.accelerationIncludingGravity ? {\n x: event.accelerationIncludingGravity.x,\n y: event.accelerationIncludingGravity.y,\n z: event.accelerationIncludingGravity.z\n } : null,\n rotationRate: event.rotationRate ? {\n alpha: event.rotationRate.alpha,\n beta: event.rotationRate.beta,\n gamma: event.rotationRate.gamma\n } : null,\n interval: event.interval || 16\n }\n });\n }\n function handleDeviceOrientation(event) {\n postToHost({\n type: "sensor-event",\n kind: "orientation",\n data: {\n alpha: event.alpha,\n beta: event.beta,\n gamma: event.gamma,\n absolute: event.absolute || false\n }\n });\n }\n function handleTerminate() {\n setInteractionEnabled(false);\n setSensorsEnabled(false);\n if (worker) {\n worker.terminate();\n worker = null;\n }\n if (workerBlobUrl) {\n URL.revokeObjectURL(workerBlobUrl);\n workerBlobUrl = null;\n }\n }\n installHostListener();\n announceReady().catch((err) => reportError(err));\n window.addEventListener("error", (e) => {\n reportError(e.error ?? new Error(e.message));\n });\n window.addEventListener("unhandledrejection", (e) => {\n reportError(e.reason ?? new Error("Unhandled promise rejection"));\n });\n})();\n';
10
10
  const IFRAME_ALLOW = "accelerometer; gyroscope; magnetometer; camera; microphone";
11
11
  const OPAQUE_ORIGIN = "null";
12
12
  function collectTransferables(msg) {
@@ -24,6 +24,7 @@ function collectTransferables(msg) {
24
24
  wasm.nosimdBinary
25
25
  );
26
26
  }
27
+ if (m.data?.controlPort) out.push(m.data.controlPort);
27
28
  return out;
28
29
  }
29
30
  case "video-canvas-setup": {
@@ -68,7 +69,8 @@ function collectBootstrapTransferables(msg) {
68
69
  msg.wasmFiles.simdLoaderJs,
69
70
  msg.wasmFiles.simdBinary,
70
71
  msg.wasmFiles.nosimdLoaderJs,
71
- msg.wasmFiles.nosimdBinary
72
+ msg.wasmFiles.nosimdBinary,
73
+ msg.controlPort
72
74
  ];
73
75
  }
74
76
  class IFrameManager {
@@ -103,6 +105,19 @@ class IFrameManager {
103
105
  hostMessageListener = null;
104
106
  workerMessageHandlers = [];
105
107
  vijiReadyHandlers = [];
108
+ /**
109
+ * Direct host↔worker control channel. `port1` stays on the host side and is
110
+ * handed to `WorkerManager` via `getControlPort1()` after `viji-ready`;
111
+ * `port2` is shipped to the worker through the bootstrap envelope so all
112
+ * steady-state host↔worker traffic can bypass the per-frame iframe relay
113
+ * tax. The trust boundary is unchanged — the worker still inherits the
114
+ * iframe's opaque origin; the port is just a faster transport.
115
+ *
116
+ * Single-owner discipline: IFrameManager owns the channel lifecycle and
117
+ * closes `port1` on destroy. WorkerManager only borrows the port reference
118
+ * for its own listener (which it detaches without closing).
119
+ */
120
+ controlChannel = null;
106
121
  initiallyVisible;
107
122
  /**
108
123
  * Enable or disable debug logging
@@ -260,6 +275,35 @@ class IFrameManager {
260
275
  onVijiReady(handler) {
261
276
  this.vijiReadyHandlers.push(handler);
262
277
  }
278
+ /**
279
+ * Create the host↔worker control channel and return the iframe-side end
280
+ * (`port2`) for inclusion in the bootstrap envelope. Idempotent: a second
281
+ * call returns the same `port2` (the underlying channel is created once
282
+ * per IFrameManager lifetime).
283
+ *
284
+ * Called by `WorkerManager.createWorker` while assembling the
285
+ * `HostBootstrapMessage` so the port hops host → iframe → worker as a
286
+ * transferable. The host-side `port1` is consumed via `getControlPort1()`
287
+ * after `viji-ready`.
288
+ */
289
+ createControlChannel() {
290
+ if (!this.controlChannel) {
291
+ this.controlChannel = new MessageChannel();
292
+ }
293
+ return this.controlChannel.port2;
294
+ }
295
+ /**
296
+ * Returns the host-side end (`port1`) of the control channel created by
297
+ * `createControlChannel()`. WorkerManager grabs this after `viji-ready` to
298
+ * route per-frame traffic directly to the worker.
299
+ *
300
+ * Returns `null` if the channel was never created (e.g. legacy bootstrap
301
+ * shape) or has been torn down by `destroy()`. Callers should treat null
302
+ * as "stay on the iframe relay path" rather than as an error.
303
+ */
304
+ getControlPort1() {
305
+ return this.controlChannel?.port1 ?? null;
306
+ }
263
307
  // ========================================
264
308
  // Host-side message dispatch
265
309
  // ========================================
@@ -425,6 +469,13 @@ class IFrameManager {
425
469
  } catch {
426
470
  }
427
471
  }
472
+ if (this.controlChannel) {
473
+ try {
474
+ this.controlChannel.port1.close();
475
+ } catch {
476
+ }
477
+ this.controlChannel = null;
478
+ }
428
479
  if (this.hostMessageListener) {
429
480
  window.removeEventListener("message", this.hostMessageListener);
430
481
  this.hostMessageListener = null;
@@ -589,7 +640,7 @@ class IFrameManager {
589
640
  }
590
641
  }
591
642
  }
592
- const workerUrl = "" + new URL("assets/viji.worker-BbzNOVhB.js", import.meta.url).href;
643
+ const workerUrl = "" + new URL("assets/viji.worker-DwYMDyfQ.js", import.meta.url).href;
593
644
  const simdLoaderJs = new URL("assets/wasm/vision_wasm_internal.js", import.meta.url).href;
594
645
  const simdBinary = new URL("assets/wasm/vision_wasm_internal.wasm", import.meta.url).href;
595
646
  const nosimdLoaderJs = new URL("assets/wasm/vision_wasm_nosimd_internal.js", import.meta.url).href;
@@ -606,6 +657,19 @@ class WorkerManager {
606
657
  isInitialized = false;
607
658
  /** Bound worker-message handler so we can detach on destroy. */
608
659
  onWorkerMessageBound = null;
660
+ /**
661
+ * Host-side end of the direct host↔worker MessageChannel (created by
662
+ * `IFrameManager.createControlChannel()` at bootstrap time, handed to us
663
+ * after `viji-ready`). Once non-null, `relayHostToWorker` posts on this
664
+ * port instead of through the iframe relay — one cross-process hop
665
+ * instead of two for every per-frame message.
666
+ *
667
+ * Single-owner discipline (docs/14, rule 5): IFrameManager owns the
668
+ * channel lifecycle and closes `port1` on destroy. WorkerManager only
669
+ * detaches its `onmessage` listener.
670
+ */
671
+ controlPort = null;
672
+ onControlPortMessageBound = null;
609
673
  /**
610
674
  * Bootstraps the worker inside the iframe.
611
675
  *
@@ -636,6 +700,7 @@ class WorkerManager {
636
700
  ]);
637
701
  this.setupMessageHandling();
638
702
  const res = this.iframeManager.getEffectiveResolution();
703
+ const controlPort2 = this.iframeManager.createControlChannel();
639
704
  const bootstrap = {
640
705
  type: "viji-bootstrap",
641
706
  workerCode,
@@ -645,6 +710,7 @@ class WorkerManager {
645
710
  nosimdLoaderJs: nosimdLoaderJs$1,
646
711
  nosimdBinary: nosimdBinary$1
647
712
  },
713
+ controlPort: controlPort2,
648
714
  init: {
649
715
  canvasWidth: res.width,
650
716
  canvasHeight: res.height,
@@ -675,6 +741,14 @@ class WorkerManager {
675
741
  collectBootstrapTransferables(bootstrap)
676
742
  );
677
743
  await vijiReady;
744
+ this.controlPort = this.iframeManager.getControlPort1();
745
+ if (this.controlPort && this.onWorkerMessageBound) {
746
+ const dispatcher = this.onWorkerMessageBound;
747
+ this.onControlPortMessageBound = (evt) => {
748
+ dispatcher(evt.data);
749
+ };
750
+ this.controlPort.onmessage = this.onControlPortMessageBound;
751
+ }
678
752
  this.postMessage("set-scene-code", { sceneCode: this.sceneCode });
679
753
  this.isInitialized = true;
680
754
  } catch (error) {
@@ -726,8 +800,17 @@ class WorkerManager {
726
800
  this.relayHostToWorker(message);
727
801
  }
728
802
  /**
729
- * Sends a host-to-worker message wrapped in a `viji-host-message`
730
- * envelope, with transferables harvested via the centralized helper.
803
+ * Sends a host-to-worker message. Once the direct control channel is
804
+ * wired (after `viji-ready`), per-message traffic flows on `port1`
805
+ * directly to the worker — bypassing the iframe relay's per-frame cost.
806
+ * During the bootstrap window (before `viji-ready` fires) and any
807
+ * post-teardown call we fall back to the iframe relay path so init-time
808
+ * messages still reach the worker.
809
+ *
810
+ * Transferables are harvested via the centralized `collectTransferables`
811
+ * helper either way, and the `transferList` shim added by parameter
812
+ * messages is stripped before send (the worker has no use for it once
813
+ * the actual handles have been transferred).
731
814
  */
732
815
  relayHostToWorker(message) {
733
816
  if (!this.iframeManager.ready && !this.isInitialized) {
@@ -739,6 +822,10 @@ class WorkerManager {
739
822
  try {
740
823
  const transfer = collectTransferables(message);
741
824
  const sanitized = stripTransferList(message);
825
+ if (this.controlPort) {
826
+ this.controlPort.postMessage(sanitized, transfer);
827
+ return;
828
+ }
742
829
  this.iframeManager.postEnvelope(
743
830
  { type: "viji-host-message", payload: sanitized },
744
831
  transfer
@@ -779,6 +866,11 @@ class WorkerManager {
779
866
  this.iframeManager.offWorkerMessage(this.onWorkerMessageBound);
780
867
  this.onWorkerMessageBound = null;
781
868
  }
869
+ if (this.controlPort && this.onControlPortMessageBound) {
870
+ this.controlPort.onmessage = null;
871
+ this.onControlPortMessageBound = null;
872
+ }
873
+ this.controlPort = null;
782
874
  this.isInitialized = false;
783
875
  } catch (error) {
784
876
  console.warn("Error during worker cleanup:", error);
@@ -1805,7 +1897,7 @@ class EssentiaOnsetDetection {
1805
1897
  this.initPromise = (async () => {
1806
1898
  try {
1807
1899
  const essentiaModule = await import("./essentia.js-core.es-DnrJE0uR.js");
1808
- const wasmModule = await import("./essentia-wasm.web-qCUJ8zjr.js").then((n) => n.e);
1900
+ const wasmModule = await import("./essentia-wasm.web-x6zu4Vib.js").then((n) => n.e);
1809
1901
  const EssentiaClass = essentiaModule.Essentia || essentiaModule.default?.Essentia || essentiaModule.default;
1810
1902
  let WASMModule = wasmModule.default || wasmModule.EssentiaWASM || wasmModule.default?.EssentiaWASM;
1811
1903
  if (!WASMModule) {
@@ -3466,10 +3558,20 @@ class TempoInduction {
3466
3558
  setDebugMode(_enabled) {
3467
3559
  }
3468
3560
  /**
3469
- * Reset tempo detection state
3561
+ * Reset tempo detection state.
3562
+ *
3563
+ * Note on coverage: this clears every mutable runtime field that affects
3564
+ * subsequent detection. Earlier versions of `reset()` missed several fields
3565
+ * (per-band envelopes, onset history, grid-lock, warmup, fpsEstimate); they
3566
+ * are included here for correctness — without them, a re-used instance
3567
+ * could carry stale signal-history into a fresh detection session.
3470
3568
  */
3471
3569
  reset() {
3472
3570
  this.onsetEnvelope = [];
3571
+ this.lowBandEnvelope = [];
3572
+ this.midBandEnvelope = [];
3573
+ this.highBandEnvelope = [];
3574
+ this.fpsEstimate = 60;
3473
3575
  this.currentBPM = 120;
3474
3576
  this.confidence = 0;
3475
3577
  this.method = "autocorr";
@@ -3482,6 +3584,107 @@ class TempoInduction {
3482
3584
  this.bpmDriftHistory = [];
3483
3585
  this.driftRate = 0;
3484
3586
  this.tempoChangeConfirmCount = 0;
3587
+ this.onsetHistory = [];
3588
+ this.gridLockBPM = null;
3589
+ this.gridLockScore = 0;
3590
+ this.gridLockTime = 0;
3591
+ this.gridLockMemoryBPM = null;
3592
+ this.gridLockMemoryTime = 0;
3593
+ this.syncopationLevel = 0;
3594
+ this.warmupStartTimeMs = 0;
3595
+ this.warmupComplete = false;
3596
+ }
3597
+ // ─────────────────────────────────────────────────────────────────────────
3598
+ // State serialization (for VijiCore.exportFullState same-process transfer)
3599
+ // ─────────────────────────────────────────────────────────────────────────
3600
+ /**
3601
+ * Snapshot continuity-relevant runtime state. Wall-clock fields are in
3602
+ * this instance's `performance.now()` clock space.
3603
+ */
3604
+ exportSessionState() {
3605
+ return {
3606
+ onsetEnvelope: [...this.onsetEnvelope],
3607
+ lowBandEnvelope: [...this.lowBandEnvelope],
3608
+ midBandEnvelope: [...this.midBandEnvelope],
3609
+ highBandEnvelope: [...this.highBandEnvelope],
3610
+ fpsEstimate: this.fpsEstimate,
3611
+ currentBPM: this.currentBPM,
3612
+ confidence: this.confidence,
3613
+ method: this.method,
3614
+ anchorBand: this.anchorBand,
3615
+ methodAgreement: this.methodAgreement,
3616
+ bpmHistory: [...this.bpmHistory],
3617
+ lastUpdateTime: this.lastUpdateTime > 0 ? this.lastUpdateTime : null,
3618
+ hypotheses: this.hypotheses.map((h) => ({
3619
+ bpm: h.bpm,
3620
+ likelihood: h.likelihood,
3621
+ age: h.age,
3622
+ lastEvidence: h.lastEvidence,
3623
+ type: h.type
3624
+ })),
3625
+ inTransition: this.inTransition,
3626
+ bpmDriftHistory: this.bpmDriftHistory.map((e) => ({ time: e.time, bpm: e.bpm })),
3627
+ driftRate: this.driftRate,
3628
+ tempoChangeConfirmCount: this.tempoChangeConfirmCount,
3629
+ onsetHistory: this.onsetHistory.map((e) => ({
3630
+ time: e.time,
3631
+ strength: e.strength,
3632
+ type: e.type
3633
+ })),
3634
+ gridLockBPM: this.gridLockBPM,
3635
+ gridLockScore: this.gridLockScore,
3636
+ gridLockTime: this.gridLockTime,
3637
+ gridLockMemoryBPM: this.gridLockMemoryBPM,
3638
+ gridLockMemoryTime: this.gridLockMemoryTime,
3639
+ syncopationLevel: this.syncopationLevel,
3640
+ warmupStartTimeMs: this.warmupStartTimeMs,
3641
+ warmupComplete: this.warmupComplete
3642
+ };
3643
+ }
3644
+ /**
3645
+ * Replace runtime state from a serialized snapshot. `clockOffset` is added
3646
+ * to all wall-clock fields (zero / null sentinels are left untranslated).
3647
+ */
3648
+ importSessionState(state, clockOffset) {
3649
+ this.onsetEnvelope = [...state.onsetEnvelope];
3650
+ this.lowBandEnvelope = [...state.lowBandEnvelope];
3651
+ this.midBandEnvelope = [...state.midBandEnvelope];
3652
+ this.highBandEnvelope = [...state.highBandEnvelope];
3653
+ this.fpsEstimate = state.fpsEstimate;
3654
+ this.currentBPM = state.currentBPM;
3655
+ this.confidence = state.confidence;
3656
+ this.method = state.method;
3657
+ this.anchorBand = state.anchorBand;
3658
+ this.methodAgreement = state.methodAgreement;
3659
+ this.bpmHistory = [...state.bpmHistory];
3660
+ this.lastUpdateTime = state.lastUpdateTime !== null ? state.lastUpdateTime + clockOffset : 0;
3661
+ this.hypotheses = state.hypotheses.map((h) => ({
3662
+ bpm: h.bpm,
3663
+ likelihood: h.likelihood,
3664
+ age: h.age,
3665
+ lastEvidence: h.lastEvidence + clockOffset,
3666
+ type: h.type
3667
+ }));
3668
+ this.inTransition = state.inTransition;
3669
+ this.bpmDriftHistory = state.bpmDriftHistory.map((e) => ({
3670
+ time: e.time + clockOffset,
3671
+ bpm: e.bpm
3672
+ }));
3673
+ this.driftRate = state.driftRate;
3674
+ this.tempoChangeConfirmCount = state.tempoChangeConfirmCount;
3675
+ this.onsetHistory = state.onsetHistory.map((e) => ({
3676
+ time: e.time + clockOffset,
3677
+ strength: e.strength,
3678
+ type: e.type
3679
+ }));
3680
+ this.gridLockBPM = state.gridLockBPM;
3681
+ this.gridLockScore = state.gridLockScore;
3682
+ this.gridLockTime = state.gridLockTime > 0 ? state.gridLockTime + clockOffset : 0;
3683
+ this.gridLockMemoryBPM = state.gridLockMemoryBPM;
3684
+ this.gridLockMemoryTime = state.gridLockMemoryTime > 0 ? state.gridLockMemoryTime + clockOffset : 0;
3685
+ this.syncopationLevel = state.syncopationLevel;
3686
+ this.warmupStartTimeMs = state.warmupStartTimeMs > 0 ? state.warmupStartTimeMs + clockOffset : 0;
3687
+ this.warmupComplete = state.warmupComplete;
3485
3688
  }
3486
3689
  /**
3487
3690
  * Get detailed debug info for testing/debugging
@@ -4001,6 +4204,75 @@ class PhaseLockedLoop {
4001
4204
  this.phaseOffsetCalibrated = 0;
4002
4205
  this.lastCalibrationTime = 0;
4003
4206
  }
4207
+ // ─────────────────────────────────────────────────────────────────────────
4208
+ // State serialization (for VijiCore.exportFullState same-process transfer)
4209
+ // ─────────────────────────────────────────────────────────────────────────
4210
+ /**
4211
+ * Snapshot continuity-relevant runtime state. Wall-clock fields are in this
4212
+ * instance's `performance.now()` clock space; receiver translates by
4213
+ * `clockOffset` on import.
4214
+ */
4215
+ exportSessionState() {
4216
+ return {
4217
+ phase: this.phase,
4218
+ lastBeatTime: this.lastBeatTime,
4219
+ periodMs: this.periodMs,
4220
+ trackedBPM: this.trackedBPM,
4221
+ beatCounter: this.beatCounter,
4222
+ lastOnsetTime: this.lastOnsetTime,
4223
+ bpmHistory: this.bpmHistory.map((e) => ({ time: e.time, bpm: e.bpm })),
4224
+ driftRate: this.driftRate,
4225
+ inBreakdown: this.inBreakdown,
4226
+ tempoConfidence: this.tempoConfidence,
4227
+ inTransition: this.inTransition,
4228
+ currentGain: this.currentGain,
4229
+ lastBarAdvanceTime: this.lastBarAdvanceTime,
4230
+ pendingBarAdvance: this.pendingBarAdvance,
4231
+ lastPhaseWrapTime: this.lastPhaseWrapTime,
4232
+ consecutiveAlignedKicks: this.consecutiveAlignedKicks,
4233
+ lastHardSyncTime: this.lastHardSyncTime,
4234
+ trackingStartTime: this.trackingStartTime,
4235
+ kickPhaseHistory: [...this.kickPhaseHistory],
4236
+ phaseOffsetCalibrated: this.phaseOffsetCalibrated,
4237
+ lastCalibrationTime: this.lastCalibrationTime,
4238
+ lastPhaseError: this.lastPhaseError
4239
+ };
4240
+ }
4241
+ /**
4242
+ * Replace runtime state from a serialized snapshot. `clockOffset` is added
4243
+ * to all wall-clock fields (zero values are treated as "never set" sentinels
4244
+ * and left at zero — same convention as `reset()`).
4245
+ */
4246
+ importSessionState(state, clockOffset) {
4247
+ this.phase = state.phase;
4248
+ this.lastBeatTime = translateTimestamp$1(state.lastBeatTime, clockOffset);
4249
+ this.periodMs = state.periodMs;
4250
+ this.trackedBPM = state.trackedBPM;
4251
+ this.beatCounter = state.beatCounter;
4252
+ this.lastOnsetTime = translateTimestamp$1(state.lastOnsetTime, clockOffset);
4253
+ this.bpmHistory = state.bpmHistory.map((e) => ({
4254
+ time: translateTimestamp$1(e.time, clockOffset),
4255
+ bpm: e.bpm
4256
+ }));
4257
+ this.driftRate = state.driftRate;
4258
+ this.inBreakdown = state.inBreakdown;
4259
+ this.tempoConfidence = state.tempoConfidence;
4260
+ this.inTransition = state.inTransition;
4261
+ this.currentGain = state.currentGain;
4262
+ this.lastBarAdvanceTime = translateTimestamp$1(state.lastBarAdvanceTime, clockOffset);
4263
+ this.pendingBarAdvance = state.pendingBarAdvance;
4264
+ this.lastPhaseWrapTime = translateTimestamp$1(state.lastPhaseWrapTime, clockOffset);
4265
+ this.consecutiveAlignedKicks = state.consecutiveAlignedKicks;
4266
+ this.lastHardSyncTime = translateTimestamp$1(state.lastHardSyncTime, clockOffset);
4267
+ this.trackingStartTime = translateTimestamp$1(state.trackingStartTime, clockOffset);
4268
+ this.kickPhaseHistory = [...state.kickPhaseHistory];
4269
+ this.phaseOffsetCalibrated = state.phaseOffsetCalibrated;
4270
+ this.lastCalibrationTime = translateTimestamp$1(state.lastCalibrationTime, clockOffset);
4271
+ this.lastPhaseError = state.lastPhaseError;
4272
+ }
4273
+ }
4274
+ function translateTimestamp$1(t, clockOffset) {
4275
+ return t > 0 ? t + clockOffset : 0;
4004
4276
  }
4005
4277
  const FAST_WINDOW_MS = 2e3;
4006
4278
  const MEDIUM_WINDOW_MS = 1e4;
@@ -5345,7 +5617,12 @@ class BeatStateManager {
5345
5617
  return this.state === "LOCKED" || this.state === "BREAKDOWN";
5346
5618
  }
5347
5619
  /**
5348
- * Reset all state
5620
+ * Reset all state.
5621
+ *
5622
+ * Coverage extended over earlier versions to also clear: per-class
5623
+ * cooldown/rate-cap state, kick interval/BPM history, onset-strength
5624
+ * tracking, band-energy history. Without these, a re-used instance
5625
+ * would carry stale signal-history into a fresh detection session.
5349
5626
  */
5350
5627
  reset() {
5351
5628
  this.state = "TRACKING";
@@ -5358,6 +5635,7 @@ class BeatStateManager {
5358
5635
  this.recentGridScores = [];
5359
5636
  this.lockedBPM = 120;
5360
5637
  this.lastOnsetTime = 0;
5638
+ this.lastKickTime = 0;
5361
5639
  this.eventBuffer = [];
5362
5640
  Object.values(this.envelopes).forEach((env) => env.reset());
5363
5641
  this.debugKickCount = 0;
@@ -5368,6 +5646,153 @@ class BeatStateManager {
5368
5646
  snare: { intervals: [], lastOnsetTime: 0 },
5369
5647
  hat: { intervals: [], lastOnsetTime: 0 }
5370
5648
  };
5649
+ this.recentOnsetStrengths = [];
5650
+ this.averageOnsetStrength = 0.5;
5651
+ this.kickIntervals = [];
5652
+ this.bpmHistory = [];
5653
+ this.bandEnergyHistory = { low: [], mid: [], high: [] };
5654
+ this.eventTimestamps = { kick: [], snare: [], hat: [] };
5655
+ this.lastEventTime = { kick: 0, snare: 0, hat: 0 };
5656
+ }
5657
+ // ─────────────────────────────────────────────────────────────────────────
5658
+ // State serialization (for VijiCore.exportFullState same-process transfer)
5659
+ // ─────────────────────────────────────────────────────────────────────────
5660
+ /**
5661
+ * Snapshot continuity-relevant runtime state. Wall-clock fields are in
5662
+ * this instance's `performance.now()` clock space. Envelope follower state
5663
+ * is intentionally omitted — receiver re-triggers envelopes naturally as
5664
+ * detected events flow.
5665
+ */
5666
+ exportSessionState() {
5667
+ return {
5668
+ state: this.state,
5669
+ stateEnteredTime: this.stateEnteredTime,
5670
+ kickProfile: { ...this.kickProfile },
5671
+ snareProfile: { ...this.snareProfile },
5672
+ hatProfile: { ...this.hatProfile },
5673
+ recentOnsetStrengths: [...this.recentOnsetStrengths],
5674
+ averageOnsetStrength: this.averageOnsetStrength,
5675
+ tempoMethodAgreement: this.tempoMethodAgreement,
5676
+ gridScore: this.gridScore,
5677
+ consistencyScore: this.consistencyScore,
5678
+ anchorClarity: this.anchorClarity,
5679
+ recentGridScores: [...this.recentGridScores],
5680
+ lockedBPM: this.lockedBPM,
5681
+ lastOnsetTime: this.lastOnsetTime,
5682
+ lastKickTime: this.lastKickTime,
5683
+ kickIntervals: [...this.kickIntervals],
5684
+ bpmHistory: [...this.bpmHistory],
5685
+ adaptiveProfiles: {
5686
+ kick: { samples: this.adaptiveProfiles.kick.samples.map((s) => ({ ...s })) },
5687
+ snareIndependent: { samples: this.adaptiveProfiles.snareIndependent.samples.map((s) => ({ ...s })) },
5688
+ snareLayered: { samples: this.adaptiveProfiles.snareLayered.samples.map((s) => ({ ...s })) },
5689
+ hatIndependent: { samples: this.adaptiveProfiles.hatIndependent.samples.map((s) => ({ ...s })) },
5690
+ hatLayered: { samples: this.adaptiveProfiles.hatLayered.samples.map((s) => ({ ...s })) }
5691
+ },
5692
+ adaptiveThresholds: {
5693
+ snareMinMidRatio: this.adaptiveThresholds.snareMinMidRatio,
5694
+ snareMinMidToBass: this.adaptiveThresholds.snareMinMidToBass,
5695
+ kickMaxMidRatio: this.adaptiveThresholds.kickMaxMidRatio,
5696
+ snareIndependent: { ...this.adaptiveThresholds.snareIndependent },
5697
+ snareLayered: { ...this.adaptiveThresholds.snareLayered },
5698
+ hatIndependent: { ...this.adaptiveThresholds.hatIndependent },
5699
+ hatLayered: { ...this.adaptiveThresholds.hatLayered }
5700
+ },
5701
+ ioiTrackers: {
5702
+ kick: { intervals: [...this.ioiTrackers.kick.intervals], lastOnsetTime: this.ioiTrackers.kick.lastOnsetTime },
5703
+ snare: { intervals: [...this.ioiTrackers.snare.intervals], lastOnsetTime: this.ioiTrackers.snare.lastOnsetTime },
5704
+ hat: { intervals: [...this.ioiTrackers.hat.intervals], lastOnsetTime: this.ioiTrackers.hat.lastOnsetTime }
5705
+ },
5706
+ eventBuffer: this.eventBuffer.map((e) => ({
5707
+ event: { ...e.event },
5708
+ expiresAt: e.expiresAt
5709
+ })),
5710
+ bandEnergyHistory: {
5711
+ low: this.bandEnergyHistory.low.map((e) => ({ ...e })),
5712
+ mid: this.bandEnergyHistory.mid.map((e) => ({ ...e })),
5713
+ high: this.bandEnergyHistory.high.map((e) => ({ ...e }))
5714
+ },
5715
+ eventTimestamps: {
5716
+ kick: [...this.eventTimestamps.kick],
5717
+ snare: [...this.eventTimestamps.snare],
5718
+ hat: [...this.eventTimestamps.hat]
5719
+ },
5720
+ lastEventTime: { ...this.lastEventTime }
5721
+ };
5722
+ }
5723
+ /**
5724
+ * Replace runtime state from a serialized snapshot. `clockOffset` is added
5725
+ * to all wall-clock fields (zero values are treated as "never set" sentinels
5726
+ * and left at zero). Envelopes are NOT restored; they re-trigger from
5727
+ * subsequent events.
5728
+ */
5729
+ importSessionState(state, clockOffset) {
5730
+ this.state = state.state;
5731
+ this.stateEnteredTime = translateTimestamp(state.stateEnteredTime, clockOffset);
5732
+ this.kickProfile = { ...state.kickProfile };
5733
+ this.snareProfile = { ...state.snareProfile };
5734
+ this.hatProfile = { ...state.hatProfile };
5735
+ this.recentOnsetStrengths = [...state.recentOnsetStrengths];
5736
+ this.averageOnsetStrength = state.averageOnsetStrength;
5737
+ this.tempoMethodAgreement = state.tempoMethodAgreement;
5738
+ this.gridScore = state.gridScore;
5739
+ this.consistencyScore = state.consistencyScore;
5740
+ this.anchorClarity = state.anchorClarity;
5741
+ this.recentGridScores = [...state.recentGridScores];
5742
+ this.lockedBPM = state.lockedBPM;
5743
+ this.lastOnsetTime = translateTimestamp(state.lastOnsetTime, clockOffset);
5744
+ this.lastKickTime = translateTimestamp(state.lastKickTime, clockOffset);
5745
+ this.kickIntervals = [...state.kickIntervals];
5746
+ this.bpmHistory = [...state.bpmHistory];
5747
+ this.adaptiveProfiles = {
5748
+ kick: { samples: state.adaptiveProfiles.kick.samples.map((s) => ({ ...s, time: s.time + clockOffset })) },
5749
+ snareIndependent: { samples: state.adaptiveProfiles.snareIndependent.samples.map((s) => ({ ...s, time: s.time + clockOffset })) },
5750
+ snareLayered: { samples: state.adaptiveProfiles.snareLayered.samples.map((s) => ({ ...s, time: s.time + clockOffset })) },
5751
+ hatIndependent: { samples: state.adaptiveProfiles.hatIndependent.samples.map((s) => ({ ...s, time: s.time + clockOffset })) },
5752
+ hatLayered: { samples: state.adaptiveProfiles.hatLayered.samples.map((s) => ({ ...s, time: s.time + clockOffset })) }
5753
+ };
5754
+ this.adaptiveThresholds = {
5755
+ snareMinMidRatio: state.adaptiveThresholds.snareMinMidRatio,
5756
+ snareMinMidToBass: state.adaptiveThresholds.snareMinMidToBass,
5757
+ kickMaxMidRatio: state.adaptiveThresholds.kickMaxMidRatio,
5758
+ snareIndependent: { ...state.adaptiveThresholds.snareIndependent },
5759
+ snareLayered: { ...state.adaptiveThresholds.snareLayered },
5760
+ hatIndependent: { ...state.adaptiveThresholds.hatIndependent },
5761
+ hatLayered: { ...state.adaptiveThresholds.hatLayered }
5762
+ };
5763
+ this.ioiTrackers = {
5764
+ kick: {
5765
+ intervals: [...state.ioiTrackers.kick.intervals],
5766
+ lastOnsetTime: translateTimestamp(state.ioiTrackers.kick.lastOnsetTime, clockOffset)
5767
+ },
5768
+ snare: {
5769
+ intervals: [...state.ioiTrackers.snare.intervals],
5770
+ lastOnsetTime: translateTimestamp(state.ioiTrackers.snare.lastOnsetTime, clockOffset)
5771
+ },
5772
+ hat: {
5773
+ intervals: [...state.ioiTrackers.hat.intervals],
5774
+ lastOnsetTime: translateTimestamp(state.ioiTrackers.hat.lastOnsetTime, clockOffset)
5775
+ }
5776
+ };
5777
+ this.eventBuffer = state.eventBuffer.map((e) => ({
5778
+ event: { ...e.event, time: e.event.time + clockOffset },
5779
+ expiresAt: e.expiresAt + clockOffset
5780
+ }));
5781
+ this.bandEnergyHistory = {
5782
+ low: state.bandEnergyHistory.low.map((e) => ({ time: e.time + clockOffset, value: e.value })),
5783
+ mid: state.bandEnergyHistory.mid.map((e) => ({ time: e.time + clockOffset, value: e.value })),
5784
+ high: state.bandEnergyHistory.high.map((e) => ({ time: e.time + clockOffset, value: e.value }))
5785
+ };
5786
+ this.eventTimestamps = {
5787
+ kick: state.eventTimestamps.kick.map((t) => t + clockOffset),
5788
+ snare: state.eventTimestamps.snare.map((t) => t + clockOffset),
5789
+ hat: state.eventTimestamps.hat.map((t) => t + clockOffset)
5790
+ };
5791
+ this.lastEventTime = {
5792
+ kick: translateTimestamp(state.lastEventTime.kick, clockOffset),
5793
+ snare: translateTimestamp(state.lastEventTime.snare, clockOffset),
5794
+ hat: translateTimestamp(state.lastEventTime.hat, clockOffset)
5795
+ };
5371
5796
  }
5372
5797
  /**
5373
5798
  * Enable/disable enhanced debug mode for comprehensive testing
@@ -5382,6 +5807,9 @@ class BeatStateManager {
5382
5807
  printSummary(durationSec) {
5383
5808
  }
5384
5809
  }
5810
+ function translateTimestamp(t, clockOffset) {
5811
+ return t > 0 ? t + clockOffset : 0;
5812
+ }
5385
5813
  class DiagnosticLogger {
5386
5814
  events = [];
5387
5815
  sessionStart = 0;
@@ -5712,6 +6140,7 @@ Rejection rate: ${(rejections / duration * 60).toFixed(1)} per minute
5712
6140
  const INSTRUMENTS = ["kick", "snare", "hat"];
5713
6141
  const SAMPLE_RATE = 60;
5714
6142
  const TAP_TIMEOUT_MS = 5e3;
6143
+ const SESSION_END_IDLE_MS = 500;
5715
6144
  const MAX_CYCLE_LENGTH = 8;
5716
6145
  const FUZZY_TOLERANCE = 0.18;
5717
6146
  const MIN_REPETITIONS = 3;
@@ -5720,6 +6149,7 @@ const MIN_TAP_INTERVAL_MS = 100;
5720
6149
  const MAX_TAP_HISTORY = 64;
5721
6150
  const MIN_EMA_ALPHA = 0.05;
5722
6151
  const PATTERN_SAME_TOLERANCE = 0.15;
6152
+ const STATE_SCHEMA_VERSION = 1;
5723
6153
  function createInstrumentState() {
5724
6154
  return {
5725
6155
  mode: "auto",
@@ -5734,7 +6164,10 @@ function createInstrumentState() {
5734
6164
  replayIndex: 0,
5735
6165
  pendingTapEvents: [],
5736
6166
  envelope: new EnvelopeFollower(0, 300, SAMPLE_RATE),
5737
- envelopeSmoothed: new EnvelopeFollower(5, 500, SAMPLE_RATE)
6167
+ envelopeSmoothed: new EnvelopeFollower(5, 500, SAMPLE_RATE),
6168
+ sessionActive: false,
6169
+ sessionEndTimer: null,
6170
+ tapTimeoutTimer: null
5738
6171
  };
5739
6172
  }
5740
6173
  class OnsetTapManager {
@@ -5743,6 +6176,9 @@ class OnsetTapManager {
5743
6176
  snare: createInstrumentState(),
5744
6177
  hat: createInstrumentState()
5745
6178
  };
6179
+ modeChangeListeners = /* @__PURE__ */ new Set();
6180
+ sessionEndListeners = /* @__PURE__ */ new Set();
6181
+ suppressEmissions = false;
5746
6182
  tap(instrument) {
5747
6183
  const s = this.state[instrument];
5748
6184
  const now = performance.now();
@@ -5764,8 +6200,10 @@ class OnsetTapManager {
5764
6200
  }
5765
6201
  s.lastTapTime = now;
5766
6202
  s.pendingTapEvents.push(now);
6203
+ s.sessionActive = true;
6204
+ this.scheduleSessionTimers(instrument);
5767
6205
  if (s.mode === "auto") {
5768
- s.mode = "tapping";
6206
+ this.setMode(instrument, "tapping");
5769
6207
  if (ioi > 0) {
5770
6208
  const pattern = this.tryRecognizePattern(instrument);
5771
6209
  if (pattern) this.applyPattern(instrument, pattern);
@@ -5783,7 +6221,9 @@ class OnsetTapManager {
5783
6221
  }
5784
6222
  clear(instrument) {
5785
6223
  const s = this.state[instrument];
5786
- s.mode = "auto";
6224
+ this.cancelSessionTimers(s);
6225
+ s.sessionActive = false;
6226
+ this.setMode(instrument, "auto");
5787
6227
  s.muted = false;
5788
6228
  s.mutedAt = 0;
5789
6229
  s.tapIOIs = [];
@@ -5828,11 +6268,103 @@ class OnsetTapManager {
5828
6268
  isMuted(instrument) {
5829
6269
  return this.state[instrument].muted;
5830
6270
  }
6271
+ // ─────────────────────────────────────────────────────────────────────────
6272
+ // Listener registration
6273
+ // ─────────────────────────────────────────────────────────────────────────
6274
+ onModeChange(listener) {
6275
+ this.modeChangeListeners.add(listener);
6276
+ return () => {
6277
+ this.modeChangeListeners.delete(listener);
6278
+ };
6279
+ }
6280
+ onSessionEnd(listener) {
6281
+ this.sessionEndListeners.add(listener);
6282
+ return () => {
6283
+ this.sessionEndListeners.delete(listener);
6284
+ };
6285
+ }
6286
+ // ─────────────────────────────────────────────────────────────────────────
6287
+ // State serialization
6288
+ // ─────────────────────────────────────────────────────────────────────────
6289
+ /**
6290
+ * Serialize per-instrument onset state for cross-instance transfer.
6291
+ * Wall-clock fields are in this instance's `performance.now()` clock space.
6292
+ */
6293
+ exportSessionState() {
6294
+ return {
6295
+ version: STATE_SCHEMA_VERSION,
6296
+ senderTime: performance.now(),
6297
+ instruments: {
6298
+ kick: this.serializeInstrument("kick"),
6299
+ snare: this.serializeInstrument("snare"),
6300
+ hat: this.serializeInstrument("hat")
6301
+ }
6302
+ };
6303
+ }
6304
+ /**
6305
+ * Replace per-instrument state from a serialized payload. Wall-clock fields
6306
+ * in the payload are translated by `clockOffset` (added to non-null sender
6307
+ * timestamps to map them into the receiver's `performance.now()` clock).
6308
+ * For same-process transfer, `clockOffset = 0`.
6309
+ *
6310
+ * `replayLastEventTime` is rebased forward by whole pattern cycles to
6311
+ * eliminate the catch-up burst that would otherwise fire if the payload
6312
+ * is older than one pattern period (phase-preserving — events still land on
6313
+ * the original beat positions modulo `patternSum`).
6314
+ *
6315
+ * Mutation is synchronous; no events are emitted (state replacement is not
6316
+ * a transition). Throws nothing — malformed payloads should be filtered by
6317
+ * the caller via the `version` field.
6318
+ */
6319
+ importSessionState(state, clockOffset) {
6320
+ if (state.version !== STATE_SCHEMA_VERSION) return;
6321
+ this.suppressEmissions = true;
6322
+ try {
6323
+ const now = performance.now();
6324
+ for (const inst of INSTRUMENTS) {
6325
+ const payload = state.instruments[inst];
6326
+ if (!payload) continue;
6327
+ this.applyInstrumentPayload(inst, payload, clockOffset, now);
6328
+ }
6329
+ } finally {
6330
+ this.suppressEmissions = false;
6331
+ }
6332
+ }
6333
+ /**
6334
+ * Validate a serialized payload's shape before importing. Returns null on
6335
+ * success or a `StateImportError` describing the first failure.
6336
+ */
6337
+ static validateOnsetPayload(state) {
6338
+ if (state === null || typeof state !== "object") {
6339
+ return { code: "malformed", details: "payload is not an object" };
6340
+ }
6341
+ const s = state;
6342
+ if (s.version !== STATE_SCHEMA_VERSION) {
6343
+ return {
6344
+ code: "version-mismatch",
6345
+ details: `expected version ${STATE_SCHEMA_VERSION}, got ${String(s.version)}`,
6346
+ ...typeof s.version === "number" ? { payloadVersion: s.version } : {},
6347
+ expectedVersion: STATE_SCHEMA_VERSION
6348
+ };
6349
+ }
6350
+ if (typeof s.senderTime !== "number") {
6351
+ return { code: "invalid-field", details: "senderTime must be a number", field: "senderTime" };
6352
+ }
6353
+ if (!s.instruments || typeof s.instruments !== "object") {
6354
+ return { code: "invalid-field", details: "instruments must be an object", field: "instruments" };
6355
+ }
6356
+ return null;
6357
+ }
5831
6358
  /**
5832
6359
  * Post-process a beat state produced by BeatStateManager.
5833
6360
  * For instruments in auto mode, values pass through unchanged.
5834
6361
  * For tapping/pattern instruments, auto events are suppressed and
5835
6362
  * tap/pattern events + envelopes are injected instead.
6363
+ *
6364
+ * Safe to call with an empty `beatState` (no audio frames available) —
6365
+ * the host's idle ticker drives this in the no-audio scenario so the
6366
+ * artist API receives tap-driven envelopes and events without an audio
6367
+ * source.
5836
6368
  */
5837
6369
  processFrame(beatState, now, dtMs) {
5838
6370
  const result = { ...beatState, events: [...beatState.events] };
@@ -5872,11 +6404,11 @@ class OnsetTapManager {
5872
6404
  s.tapIOIs = [];
5873
6405
  s.pendingTapEvents = [];
5874
6406
  if (s.pattern) {
5875
- s.mode = "pattern";
6407
+ this.setMode(inst, "pattern");
5876
6408
  s.replayLastEventTime = now;
5877
6409
  s.replayIndex = 0;
5878
6410
  } else {
5879
- s.mode = "auto";
6411
+ this.setMode(inst, "auto");
5880
6412
  }
5881
6413
  continue;
5882
6414
  }
@@ -5931,6 +6463,159 @@ class OnsetTapManager {
5931
6463
  // ---------------------------------------------------------------------------
5932
6464
  // Private helpers
5933
6465
  // ---------------------------------------------------------------------------
6466
+ /**
6467
+ * Single mutation point for `s.mode`. Fires `onModeChange` (when not
6468
+ * suppressed) so listeners stay consistent regardless of which code path
6469
+ * triggered the transition.
6470
+ */
6471
+ setMode(instrument, newMode) {
6472
+ const s = this.state[instrument];
6473
+ const prevMode = s.mode;
6474
+ if (prevMode === newMode) return;
6475
+ s.mode = newMode;
6476
+ if (!this.suppressEmissions) {
6477
+ this.fireModeChange({ instrument, prevMode, newMode });
6478
+ }
6479
+ }
6480
+ fireModeChange(ev) {
6481
+ for (const listener of this.modeChangeListeners) {
6482
+ try {
6483
+ listener(ev);
6484
+ } catch (err) {
6485
+ console.error("Error in onModeChange listener:", err);
6486
+ }
6487
+ }
6488
+ }
6489
+ fireSessionEnd(ev) {
6490
+ for (const listener of this.sessionEndListeners) {
6491
+ try {
6492
+ listener(ev);
6493
+ } catch (err) {
6494
+ console.error("Error in onSessionEnd listener:", err);
6495
+ }
6496
+ }
6497
+ }
6498
+ /**
6499
+ * Schedule (or reschedule) the per-instrument session timers on every tap.
6500
+ * 500ms timer fires `'pattern'` outcome if instrument is in pattern mode at
6501
+ * fire time. 5s timer fires `'cleared'` outcome if a tapping session
6502
+ * never reached pattern recognition. Either timer transitions mode if the
6503
+ * audio-driven `processFrame` hasn't already done so (controller-side has
6504
+ * no audio path).
6505
+ */
6506
+ scheduleSessionTimers(instrument) {
6507
+ const s = this.state[instrument];
6508
+ this.cancelSessionTimers(s);
6509
+ s.sessionEndTimer = setTimeout(() => this.onSessionEndTimer(instrument), SESSION_END_IDLE_MS);
6510
+ s.tapTimeoutTimer = setTimeout(() => this.onTapTimeoutTimer(instrument), TAP_TIMEOUT_MS);
6511
+ }
6512
+ cancelSessionTimers(s) {
6513
+ if (s.sessionEndTimer !== null) {
6514
+ clearTimeout(s.sessionEndTimer);
6515
+ s.sessionEndTimer = null;
6516
+ }
6517
+ if (s.tapTimeoutTimer !== null) {
6518
+ clearTimeout(s.tapTimeoutTimer);
6519
+ s.tapTimeoutTimer = null;
6520
+ }
6521
+ }
6522
+ /**
6523
+ * 500ms idle timer. Fires `'pattern'` outcome iff the instrument is in
6524
+ * `'pattern'` mode (meaning a recognized pattern survived the idle window).
6525
+ * Other modes are handled by the 5s timer.
6526
+ */
6527
+ onSessionEndTimer(instrument) {
6528
+ const s = this.state[instrument];
6529
+ s.sessionEndTimer = null;
6530
+ if (!s.sessionActive) return;
6531
+ if (s.mode === "pattern" && s.pattern) {
6532
+ s.sessionActive = false;
6533
+ this.fireSessionEnd({ instrument, outcome: "pattern" });
6534
+ if (s.tapTimeoutTimer !== null) {
6535
+ clearTimeout(s.tapTimeoutTimer);
6536
+ s.tapTimeoutTimer = null;
6537
+ }
6538
+ }
6539
+ }
6540
+ /**
6541
+ * 5s tap-idle timer. Resolves the `'cleared'` outcome whether or not
6542
+ * `processFrame` has already transitioned the mode (audio-active path can
6543
+ * race ahead and transition `'tapping' → 'auto'` without firing the event;
6544
+ * we detect that case via `sessionActive` and fire here regardless).
6545
+ */
6546
+ onTapTimeoutTimer(instrument) {
6547
+ const s = this.state[instrument];
6548
+ s.tapTimeoutTimer = null;
6549
+ if (!s.sessionActive) return;
6550
+ if (s.mode === "tapping") {
6551
+ s.tapIOIs = [];
6552
+ s.pendingTapEvents = [];
6553
+ if (s.pattern) {
6554
+ this.setMode(instrument, "pattern");
6555
+ s.replayLastEventTime = performance.now();
6556
+ s.replayIndex = 0;
6557
+ s.sessionActive = false;
6558
+ this.fireSessionEnd({ instrument, outcome: "pattern" });
6559
+ } else {
6560
+ this.setMode(instrument, "auto");
6561
+ s.sessionActive = false;
6562
+ this.fireSessionEnd({ instrument, outcome: "cleared" });
6563
+ }
6564
+ } else if (s.mode === "auto") {
6565
+ s.sessionActive = false;
6566
+ this.fireSessionEnd({ instrument, outcome: "cleared" });
6567
+ } else {
6568
+ s.sessionActive = false;
6569
+ }
6570
+ }
6571
+ serializeInstrument(instrument) {
6572
+ const s = this.state[instrument];
6573
+ return {
6574
+ mode: s.mode,
6575
+ muted: s.muted,
6576
+ pattern: s.pattern ? [...s.pattern] : null,
6577
+ replayLastEventTime: s.replayLastEventTime > 0 ? s.replayLastEventTime : null,
6578
+ replayIndex: s.replayIndex,
6579
+ tapIOIs: [...s.tapIOIs],
6580
+ lastTapTime: s.lastTapTime > 0 ? s.lastTapTime : null,
6581
+ mutedAt: s.mutedAt > 0 ? s.mutedAt : null,
6582
+ refinementIndex: s.refinementIndex,
6583
+ refinementCounts: [...s.refinementCounts]
6584
+ };
6585
+ }
6586
+ applyInstrumentPayload(instrument, payload, clockOffset, now) {
6587
+ const s = this.state[instrument];
6588
+ this.cancelSessionTimers(s);
6589
+ s.sessionActive = false;
6590
+ const translatedReplayLast = payload.replayLastEventTime !== null ? payload.replayLastEventTime + clockOffset : 0;
6591
+ const translatedLastTap = payload.lastTapTime !== null ? payload.lastTapTime + clockOffset : 0;
6592
+ const translatedMutedAt = payload.mutedAt !== null ? payload.mutedAt + clockOffset : 0;
6593
+ let rebasedReplayLast = translatedReplayLast;
6594
+ if (payload.pattern && payload.pattern.length > 0 && translatedReplayLast > 0) {
6595
+ const patternSum = payload.pattern.reduce((a, b) => a + b, 0);
6596
+ if (patternSum > 0) {
6597
+ const elapsed = now - translatedReplayLast;
6598
+ if (elapsed > patternSum) {
6599
+ const cycles = Math.floor(elapsed / patternSum);
6600
+ rebasedReplayLast = translatedReplayLast + cycles * patternSum;
6601
+ }
6602
+ }
6603
+ }
6604
+ s.mode;
6605
+ s.mode = payload.mode;
6606
+ s.muted = payload.muted;
6607
+ s.mutedAt = translatedMutedAt;
6608
+ s.tapIOIs = [...payload.tapIOIs];
6609
+ s.lastTapTime = translatedLastTap;
6610
+ s.pattern = payload.pattern ? [...payload.pattern] : null;
6611
+ s.refinementIndex = payload.refinementIndex;
6612
+ s.refinementCounts = [...payload.refinementCounts];
6613
+ s.replayLastEventTime = rebasedReplayLast;
6614
+ s.replayIndex = payload.replayIndex;
6615
+ s.pendingTapEvents = [];
6616
+ s.envelope.reset();
6617
+ s.envelopeSmoothed.reset();
6618
+ }
5934
6619
  /**
5935
6620
  * Handle a tap that arrives while already in pattern mode.
5936
6621
  * Matching taps refine the pattern via EMA and re-anchor phase.
@@ -6039,7 +6724,7 @@ class OnsetTapManager {
6039
6724
  applyPattern(instrument, pattern) {
6040
6725
  const s = this.state[instrument];
6041
6726
  s.pattern = pattern;
6042
- s.mode = "pattern";
6727
+ this.setMode(instrument, "pattern");
6043
6728
  s.refinementIndex = 0;
6044
6729
  s.refinementCounts = new Array(pattern.length).fill(MIN_REPETITIONS);
6045
6730
  if (s.replayLastEventTime === 0 || s.replayLastEventTime < s.lastTapTime) {
@@ -6748,6 +7433,13 @@ class AudioSystem {
6748
7433
  beatDetectionEnabled = true;
6749
7434
  onsetDetectionEnabled = true;
6750
7435
  autoGainEnabled = true;
7436
+ // Idle ticker — drives `OnsetTapManager.processFrame` via rAF when no audio
7437
+ // is connected, so taps still produce envelope/event output through the
7438
+ // artist API. Paused when audio connects (worklet/analyser path takes over);
7439
+ // resumed on disconnect. See lifecycle methods below for the strict
7440
+ // start-stop ordering invariant.
7441
+ idleTickerHandle = null;
7442
+ idleTickerLastTime = 0;
6751
7443
  /**
6752
7444
  * Enable or disable comprehensive debug logging for all layers
6753
7445
  * Enables enhanced logging in: MultiOnsetDetection, BeatStateManager
@@ -7212,7 +7904,10 @@ class AudioSystem {
7212
7904
  }
7213
7905
  // Analysis configuration
7214
7906
  fftSize = 2048;
7215
- // Beat state for main channel (not on AudioChannel because it's main-only)
7907
+ // Beat state for main channel (not on AudioChannel because it's main-only).
7908
+ // `bpm: 0` is the documented "no signal" sentinel; `runBeatPipeline`'s
7909
+ // `lastNonZeroBpm` carry-over fills in a meaningful value as soon as audio
7910
+ // is connected and the first BPM hypothesis is formed.
7216
7911
  audioStateBeat = {
7217
7912
  kick: 0,
7218
7913
  snare: 0,
@@ -7223,7 +7918,7 @@ class AudioSystem {
7223
7918
  hatSmoothed: 0,
7224
7919
  anySmoothed: 0,
7225
7920
  events: [],
7226
- bpm: 120,
7921
+ bpm: 0,
7227
7922
  confidence: 0,
7228
7923
  isLocked: false
7229
7924
  };
@@ -7253,6 +7948,8 @@ class AudioSystem {
7253
7948
  anySmoothed: new EnvelopeFollower(5, 500, sampleRate)
7254
7949
  };
7255
7950
  this.resetEssentiaBandHistories();
7951
+ this.tickIdle = this.tickIdle.bind(this);
7952
+ this.startIdleTicker();
7256
7953
  }
7257
7954
  /**
7258
7955
  * Get the current audio analysis state (for host-side usage)
@@ -7318,8 +8015,17 @@ class AudioSystem {
7318
8015
  /**
7319
8016
  * Connect a channel to Web Audio nodes (source, worklet/analyser).
7320
8017
  * Used for both main and additional channels.
8018
+ *
8019
+ * Lifecycle ordering invariant: when wiring the main channel, the idle
8020
+ * ticker MUST be stopped before the first `await` so that an in-flight
8021
+ * tick cannot race the audio path coming online. The ticker callback
8022
+ * additionally guards on `mainChannel.audioState.isConnected` for
8023
+ * belt-and-braces.
7321
8024
  */
7322
8025
  async connectChannel(ch, audioStream, isMain) {
8026
+ if (isMain) {
8027
+ this.stopIdleTicker();
8028
+ }
7323
8029
  ch.disconnectNodes();
7324
8030
  ch.refreshFFTResources();
7325
8031
  ch.workletFrameCount = 0;
@@ -7328,6 +8034,7 @@ class AudioSystem {
7328
8034
  console.warn(`No audio tracks in stream for channel ${ch.streamIndex}`);
7329
8035
  ch.audioState.isConnected = false;
7330
8036
  this.sendChannelResults(ch, isMain);
8037
+ if (isMain) this.startIdleTicker();
7331
8038
  return;
7332
8039
  }
7333
8040
  try {
@@ -7390,6 +8097,7 @@ class AudioSystem {
7390
8097
  console.error(`Failed to set up audio for channel ${ch.streamIndex}:`, error);
7391
8098
  ch.audioState.isConnected = false;
7392
8099
  ch.disconnectNodes();
8100
+ if (isMain) this.startIdleTicker();
7393
8101
  }
7394
8102
  this.sendChannelResults(ch, isMain);
7395
8103
  }
@@ -7403,17 +8111,30 @@ class AudioSystem {
7403
8111
  }
7404
8112
  /**
7405
8113
  * Disconnect the main audio stream (does NOT close AudioContext -- additional channels may still be active).
8114
+ *
8115
+ * Lifecycle ordering invariant: tear down the audio nodes first, clear any
8116
+ * stale frequency / waveform buffers, then restart the idle ticker. The
8117
+ * ticker callback's defensive `isConnected` guard prevents any stale tick
8118
+ * from observing a half-disconnected state.
7406
8119
  */
7407
8120
  disconnectMainStream() {
7408
8121
  this.mainChannel.disconnectNodes();
7409
8122
  this.mainChannel.audioState.isConnected = false;
7410
8123
  this.mainChannel.isAnalysisRunning = false;
7411
8124
  this.mainChannel.currentStream = null;
8125
+ if (this.mainChannel.frequencyData) {
8126
+ this.mainChannel.frequencyData.fill(0);
8127
+ }
8128
+ if (this.mainChannel.timeDomainData) {
8129
+ this.mainChannel.timeDomainData.fill(0);
8130
+ }
8131
+ this.mainChannel.lastWaveformFrame = null;
7412
8132
  this.resetAudioValues();
7413
8133
  if (this.additionalChannels.size === 0) {
7414
8134
  this.stopAnalysisLoop();
7415
8135
  this.stopStalenessTimer();
7416
8136
  }
8137
+ this.startIdleTicker();
7417
8138
  this.sendChannelResults(this.mainChannel, true);
7418
8139
  this.debugLog("Main audio stream disconnected (host-side)");
7419
8140
  }
@@ -7647,7 +8368,7 @@ class AudioSystem {
7647
8368
  hatSmoothed: 0,
7648
8369
  anySmoothed: 0,
7649
8370
  events: [],
7650
- bpm: 120,
8371
+ bpm: 0,
7651
8372
  confidence: 0,
7652
8373
  isLocked: false
7653
8374
  };
@@ -7659,6 +8380,7 @@ class AudioSystem {
7659
8380
  resetAudioState() {
7660
8381
  this.stopAnalysisLoop();
7661
8382
  this.stopStalenessTimer();
8383
+ this.stopIdleTicker();
7662
8384
  this.resetEssentiaBandHistories();
7663
8385
  for (const ch of this.additionalChannels.values()) {
7664
8386
  ch.destroy();
@@ -7783,6 +8505,94 @@ class AudioSystem {
7783
8505
  isOnsetMuted(instrument) {
7784
8506
  return this.onsetTapManager.isMuted(instrument);
7785
8507
  }
8508
+ // ─────────────────────────────────────────────────────────────────────────
8509
+ // Onset event subscriptions (forward to OnsetTapManager).
8510
+ // Returned `Unsubscribe` removes the listener.
8511
+ // ─────────────────────────────────────────────────────────────────────────
8512
+ onOnsetModeChange(listener) {
8513
+ return this.onsetTapManager.onModeChange(listener);
8514
+ }
8515
+ onOnsetSessionEnd(listener) {
8516
+ return this.onsetTapManager.onSessionEnd(listener);
8517
+ }
8518
+ // ─────────────────────────────────────────────────────────────────────────
8519
+ // State serialization. Onset-only export is cross-device-safe; the full
8520
+ // audio block is for same-process scene-switch transfer (sender's audio
8521
+ // analysis state would corrupt receiver's tracking on a different source).
8522
+ // ─────────────────────────────────────────────────────────────────────────
8523
+ exportOnsetSessionState() {
8524
+ return this.onsetTapManager.exportSessionState();
8525
+ }
8526
+ importOnsetSessionState(state, clockOffset) {
8527
+ this.onsetTapManager.importSessionState(state, clockOffset);
8528
+ }
8529
+ exportAudioAnalysisState() {
8530
+ return {
8531
+ onset: this.onsetTapManager.exportSessionState().instruments,
8532
+ bpmTracker: this.tempoInduction.exportSessionState(),
8533
+ pll: this.pll.exportSessionState(),
8534
+ beatState: this.stateManager.exportSessionState()
8535
+ };
8536
+ }
8537
+ importAudioAnalysisState(state, clockOffset) {
8538
+ this.onsetTapManager.importSessionState(
8539
+ { version: 1, senderTime: performance.now(), instruments: state.onset },
8540
+ clockOffset
8541
+ );
8542
+ this.tempoInduction.importSessionState(state.bpmTracker, clockOffset);
8543
+ this.pll.importSessionState(state.pll, clockOffset);
8544
+ this.stateManager.importSessionState(state.beatState, clockOffset);
8545
+ }
8546
+ // ─────────────────────────────────────────────────────────────────────────
8547
+ // Idle ticker — drives `OnsetTapManager.processFrame` via rAF when no audio
8548
+ // is connected. Constructs an empty `beatState` and reuses the existing
8549
+ // `processFrame` (verified correctness-preserving on empty input), so taps
8550
+ // produce envelope/event output through the artist API the same way they
8551
+ // do when audio is active.
8552
+ // ─────────────────────────────────────────────────────────────────────────
8553
+ startIdleTicker() {
8554
+ if (this.idleTickerHandle !== null) return;
8555
+ if (typeof requestAnimationFrame === "undefined") return;
8556
+ this.idleTickerLastTime = performance.now();
8557
+ this.idleTickerHandle = requestAnimationFrame(this.tickIdle);
8558
+ }
8559
+ stopIdleTicker() {
8560
+ if (this.idleTickerHandle === null) return;
8561
+ if (typeof cancelAnimationFrame !== "undefined") {
8562
+ cancelAnimationFrame(this.idleTickerHandle);
8563
+ }
8564
+ this.idleTickerHandle = null;
8565
+ }
8566
+ tickIdle(now) {
8567
+ this.idleTickerHandle = null;
8568
+ if (this.mainChannel.audioState.isConnected) return;
8569
+ const dtMs = Math.min(100, now - this.idleTickerLastTime);
8570
+ this.idleTickerLastTime = now;
8571
+ if (this.onsetDetectionEnabled && this.beatDetectionEnabled) {
8572
+ const empty = this.makeEmptyBeatState();
8573
+ this.audioStateBeat = this.onsetTapManager.processFrame(empty, now, dtMs);
8574
+ this.sendChannelResults(this.mainChannel, true);
8575
+ }
8576
+ if (typeof requestAnimationFrame !== "undefined") {
8577
+ this.idleTickerHandle = requestAnimationFrame(this.tickIdle);
8578
+ }
8579
+ }
8580
+ makeEmptyBeatState() {
8581
+ return {
8582
+ kick: 0,
8583
+ snare: 0,
8584
+ hat: 0,
8585
+ any: 0,
8586
+ kickSmoothed: 0,
8587
+ snareSmoothed: 0,
8588
+ hatSmoothed: 0,
8589
+ anySmoothed: 0,
8590
+ events: [],
8591
+ bpm: 0,
8592
+ confidence: 0,
8593
+ isLocked: false
8594
+ };
8595
+ }
7786
8596
  /**
7787
8597
  * Get current BPM (manual or auto-detected)
7788
8598
  */
@@ -8821,6 +9631,10 @@ class VijiCore {
8821
9631
  parameterDefinedListeners = /* @__PURE__ */ new Set();
8822
9632
  parameterErrorListeners = /* @__PURE__ */ new Set();
8823
9633
  capabilitiesChangeListeners = /* @__PURE__ */ new Set();
9634
+ // State-import error listeners. Fire on `importFullState` payloads that fail
9635
+ // version or shape validation; consumers surface meaningful UI feedback
9636
+ // rather than relying on console.warn.
9637
+ stateImportErrorListeners = /* @__PURE__ */ new Set();
8824
9638
  // Performance tracking (basic for Phase 1)
8825
9639
  stats = {
8826
9640
  frameTime: 0,
@@ -9793,6 +10607,86 @@ class VijiCore {
9793
10607
  offParameterError(listener) {
9794
10608
  this.parameterErrorListeners.delete(listener);
9795
10609
  }
10610
+ // ─────────────────────────────────────────────────────────────────────────
10611
+ // State serialization (cross-instance transfer)
10612
+ // ─────────────────────────────────────────────────────────────────────────
10613
+ /**
10614
+ * Snapshot the full audio analysis + onset state for cross-instance
10615
+ * transfer. Use for **same-process scene-switch** continuity (a fresh
10616
+ * `VijiCore` can resume detection where the previous one left off).
10617
+ *
10618
+ * For **cross-device** transfer, prefer `audio.onset.exportSessionState`
10619
+ * — the controller's audio analysis state doesn't apply to the host's
10620
+ * audio source and would corrupt detection.
10621
+ *
10622
+ * Wall-clock fields are in this instance's `performance.now()` clock space.
10623
+ * Receiver applies `clockOffset` on import (use `0` for same-process).
10624
+ *
10625
+ * **Staleness caveat**: the audio block is robust to scene-load gaps under
10626
+ * ~1 second. Longer gaps (>5s, e.g. cold cache + slow network) may cause
10627
+ * stale-timestamp drift in `gridLockMemoryTime` / `warmupStartTimeMs`;
10628
+ * callers should treat that as a degraded path and skip the audio block.
10629
+ */
10630
+ exportFullState() {
10631
+ this.validateReady();
10632
+ const senderTime = performance.now();
10633
+ const out = { version: 1, senderTime };
10634
+ if (this.audioSystem) {
10635
+ const audio = this.audioSystem.exportAudioAnalysisState();
10636
+ out.onset = audio.onset;
10637
+ out.audio = audio;
10638
+ }
10639
+ return out;
10640
+ }
10641
+ /**
10642
+ * Replace the audio analysis + onset state from a serialized payload.
10643
+ * `clockOffset` is added to all sender-clocked fields (`0` for same-process,
10644
+ * NTP-derived for cross-device). Mutation is synchronous; no `onModeChange`
10645
+ * or `onSessionEnd` events are emitted (state replacement is not a transition).
10646
+ *
10647
+ * Validates `version`; on mismatch, fires `onStateImportError` and leaves
10648
+ * existing state intact.
10649
+ */
10650
+ importFullState(state, clockOffset) {
10651
+ this.validateReady();
10652
+ const validation = validateCoreStatePayload(state);
10653
+ if (validation) {
10654
+ this.fireStateImportError(validation);
10655
+ return;
10656
+ }
10657
+ if (!this.audioSystem) return;
10658
+ if (state.audio) {
10659
+ this.audioSystem.importAudioAnalysisState(state.audio, clockOffset);
10660
+ } else if (state.onset) {
10661
+ this.audioSystem.importOnsetSessionState(
10662
+ { version: 1, senderTime: state.senderTime, instruments: state.onset },
10663
+ clockOffset
10664
+ );
10665
+ }
10666
+ }
10667
+ /**
10668
+ * Listen for state-import validation errors. Fires when `importFullState`
10669
+ * or `audio.onset.importSessionState` rejects a payload (version mismatch,
10670
+ * malformed shape, invalid field). Existing state is left intact when
10671
+ * an error fires.
10672
+ *
10673
+ * @returns Unsubscribe function. Call to remove the listener.
10674
+ */
10675
+ onStateImportError(listener) {
10676
+ this.stateImportErrorListeners.add(listener);
10677
+ return () => {
10678
+ this.stateImportErrorListeners.delete(listener);
10679
+ };
10680
+ }
10681
+ fireStateImportError(error) {
10682
+ for (const listener of this.stateImportErrorListeners) {
10683
+ try {
10684
+ listener(error);
10685
+ } catch (err) {
10686
+ console.error("Error in onStateImportError listener:", err);
10687
+ }
10688
+ }
10689
+ }
9796
10690
  /**
9797
10691
  * Notify parameter change listeners
9798
10692
  */
@@ -10221,6 +11115,73 @@ class VijiCore {
10221
11115
  isMuted: (instrument) => {
10222
11116
  this.validateReady();
10223
11117
  return this.audioSystem?.isOnsetMuted(instrument) ?? false;
11118
+ },
11119
+ /**
11120
+ * Listen for instrument mode transitions (`'auto' | 'tapping' | 'pattern'`).
11121
+ * Fires on every transition including the first tap (`'auto' → 'tapping'`)
11122
+ * and pattern recognition (`'tapping' → 'pattern'`). Imported state does
11123
+ * NOT fire mode-change events — state replacement is not a transition.
11124
+ *
11125
+ * @returns Unsubscribe function. Call to remove the listener.
11126
+ */
11127
+ onModeChange: (listener) => {
11128
+ this.validateReady();
11129
+ return this.audioSystem?.onOnsetModeChange(listener) ?? (() => {
11130
+ });
11131
+ },
11132
+ /**
11133
+ * Listen for natural session-end events. Fires when:
11134
+ * - 500ms elapse since last tap with instrument in `'pattern'` mode
11135
+ * (outcome `'pattern'`), or
11136
+ * - 5s elapse in `'tapping'` mode without a recognized pattern
11137
+ * (outcome `'cleared'`; instrument transitions to `'auto'`).
11138
+ *
11139
+ * Explicit `clear()` calls do NOT fire this event (caller-initiated;
11140
+ * caller already knows). Imported state does NOT fire either.
11141
+ *
11142
+ * @returns Unsubscribe function. Call to remove the listener.
11143
+ */
11144
+ onSessionEnd: (listener) => {
11145
+ this.validateReady();
11146
+ return this.audioSystem?.onOnsetSessionEnd(listener) ?? (() => {
11147
+ });
11148
+ },
11149
+ /**
11150
+ * Snapshot per-instrument onset state for cross-instance transfer.
11151
+ * Cross-device-safe (no audio analysis state included). Pair with
11152
+ * `importSessionState` on a receiver. Wall-clock fields are in this
11153
+ * instance's `performance.now()` clock space.
11154
+ */
11155
+ exportSessionState: () => {
11156
+ this.validateReady();
11157
+ return this.audioSystem?.exportOnsetSessionState() ?? {
11158
+ version: 1,
11159
+ senderTime: performance.now(),
11160
+ instruments: {}
11161
+ };
11162
+ },
11163
+ /**
11164
+ * Replace per-instrument onset state from a serialized payload.
11165
+ * `clockOffset` is added to all sender-clocked fields during import
11166
+ * (use `0` for same-process transfer; NTP-derived offset for cross-device).
11167
+ * Mutation is synchronous; no events are emitted.
11168
+ *
11169
+ * Patterns are rebased forward by whole pattern cycles to eliminate
11170
+ * the catch-up burst that would otherwise occur on stale payloads
11171
+ * (phase-preserving — events still land on the original beat positions
11172
+ * modulo pattern length).
11173
+ *
11174
+ * Validates `version`; on mismatch, fires `onStateImportError` and
11175
+ * leaves existing state intact.
11176
+ */
11177
+ importSessionState: (state, clockOffset) => {
11178
+ this.validateReady();
11179
+ const validation = OnsetTapManager.validateOnsetPayload(state);
11180
+ if (validation) {
11181
+ this.fireStateImportError(validation);
11182
+ return;
11183
+ }
11184
+ this.audioSystem?.importOnsetSessionState(state, clockOffset);
10224
11185
  }
10225
11186
  },
10226
11187
  /**
@@ -10510,6 +11471,7 @@ class VijiCore {
10510
11471
  this.parameterDefinedListeners.clear();
10511
11472
  this.parameterErrorListeners.clear();
10512
11473
  this.capabilitiesChangeListeners.clear();
11474
+ this.stateImportErrorListeners.clear();
10513
11475
  this.unlinkEventSource();
10514
11476
  this.unlinkFrameSources();
10515
11477
  for (const [deviceId] of this.deviceVideoCoordinators) {
@@ -10601,7 +11563,25 @@ class VijiCore {
10601
11563
  }
10602
11564
  }
10603
11565
  }
10604
- const VERSION = "0.3.29";
11566
+ function validateCoreStatePayload(state) {
11567
+ if (state === null || typeof state !== "object") {
11568
+ return { code: "malformed", details: "payload is not an object" };
11569
+ }
11570
+ const s = state;
11571
+ if (s.version !== 1) {
11572
+ return {
11573
+ code: "version-mismatch",
11574
+ details: `expected version 1, got ${String(s.version)}`,
11575
+ ...typeof s.version === "number" ? { payloadVersion: s.version } : {},
11576
+ expectedVersion: 1
11577
+ };
11578
+ }
11579
+ if (typeof s.senderTime !== "number") {
11580
+ return { code: "invalid-field", details: "senderTime must be a number", field: "senderTime" };
11581
+ }
11582
+ return null;
11583
+ }
11584
+ const VERSION = "0.5.1";
10605
11585
  export {
10606
11586
  AudioSystem as A,
10607
11587
  VERSION as V,
@@ -10609,4 +11589,4 @@ export {
10609
11589
  VijiCoreError as b,
10610
11590
  getDefaultExportFromCjs as g
10611
11591
  };
10612
- //# sourceMappingURL=index-Oj_9v8r4.js.map
11592
+ //# sourceMappingURL=index-Cqh1k_49.js.map