@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.
- package/dist/artist-dts-p5.js +1 -1
- package/dist/artist-dts.js +1 -1
- package/dist/artist-global-p5.d.ts +114 -48
- package/dist/artist-global.d.ts +114 -48
- package/dist/artist-jsdoc.d.ts +2 -6
- package/dist/assets/{viji.worker-BbzNOVhB.js → viji.worker-DwYMDyfQ.js} +539 -170
- package/dist/assets/viji.worker-DwYMDyfQ.js.map +1 -0
- package/dist/docs-api.js +548 -458
- package/dist/{essentia-wasm.web-qCUJ8zjr.js → essentia-wasm.web-x6zu4Vib.js} +2 -2
- package/dist/{essentia-wasm.web-qCUJ8zjr.js.map → essentia-wasm.web-x6zu4Vib.js.map} +1 -1
- package/dist/{index-Oj_9v8r4.js → index-Cqh1k_49.js} +999 -19
- package/dist/index-Cqh1k_49.js.map +1 -0
- package/dist/index.d.ts +571 -48
- package/dist/index.js +1 -1
- package/dist/shader-uniforms.js +10 -0
- package/package.json +1 -1
- package/dist/assets/viji.worker-BbzNOVhB.js.map +0 -1
- package/dist/index-Oj_9v8r4.js.map +0 -1
|
@@ -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-
|
|
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
|
|
730
|
-
*
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
6407
|
+
this.setMode(inst, "pattern");
|
|
5876
6408
|
s.replayLastEventTime = now;
|
|
5877
6409
|
s.replayIndex = 0;
|
|
5878
6410
|
} else {
|
|
5879
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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-
|
|
11592
|
+
//# sourceMappingURL=index-Cqh1k_49.js.map
|