import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js"; import { validateExperiment, validateResult, validateScene } from "/packages/contracts/validation.js"; async function waitFor(predicate, label, timeoutMs = 30000) { const deadline = performance.now() + timeoutMs; while (performance.now() < deadline) { const value = predicate(); if (value) return value; await new Promise((resolve) => setTimeout(resolve, 40)); } throw new Error(`${label} timed out.`); } function dispatchValue(windowObject, element, value, eventName = "input") { element.value = String(value); element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true })); } function dispatchChecked(windowObject, element, checked) { element.checked = checked; element.dispatchEvent(new windowObject.Event("change", { bubbles: true })); } function dispatchFiles(windowObject, input, files) { const transfer = new windowObject.DataTransfer(); files.forEach((file) => transfer.items.add(file)); input.files = transfer.files; input.dispatchEvent(new windowObject.Event("change", { bubbles: true })); } async function mountApp(path, label) { const frame = document.createElement("iframe"); frame.src = `${path}?integration-test=1`; frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0"; document.body.append(frame); await new Promise((resolve, reject) => { frame.addEventListener("load", resolve, { once: true }); frame.addEventListener("error", () => reject(new Error(`${label} iframe load failed`)), { once: true }); }); await waitFor( () => frame.contentDocument.documentElement.dataset.appReady === "true", `${label} initialization`, ); return frame; } function captureDownloads(windowObject) { const downloads = []; const objectUrls = []; const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click; const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL); windowObject.HTMLAnchorElement.prototype.click = function captureDownload() { downloads.push({ fileName: this.download, url: this.href }); }; windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url); return { downloads, restore() { windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick; windowObject.URL.revokeObjectURL = originalRevoke; for (const url of objectUrls) originalRevoke(url); }, }; } function makeExperiment(scene) { const plane = scene.image.plane; return { schemaVersion: "1.0.0", kind: "micarrsim.experiment", id: "builder-camera-workflow", name: "Builder to Camera asset workflow", arrayId: "rectangular-16x12", sceneId: scene.id, scan: { z: plane.z, xMin: plane.xMin, xMax: plane.xMax, yMin: plane.yMin, yMax: plane.yMax, columns: 8, rows: 8, }, frequencyBands: [ { id: "asset-500", minHz: 450, maxHz: 550, color: "#65f2bf" }, { id: "tone-1k", minHz: 950, maxHz: 1050, color: "#58c7e8" }, ], stft: { fftSize: 128, hopSize: 64, window: "hann" }, beamforming: { algorithm: "das", backend: "cpu", minimumDistance: 0.01, normalization: "peak", dynamicRangeDb: 30, }, }; } test("Dataset Builder exports asset Scene that Acoustic Camera rebinds and runs", async () => { const sampleRate = 8000; const duration = 0.05; const samples = new Float32Array(sampleRate * duration); for (let index = 0; index < samples.length; index += 1) { samples[index] = Math.sin((2 * Math.PI * 500 * index) / sampleRate) * 0.2; } const audioBytes = await encodeMonoPcm16Wav(samples, sampleRate).arrayBuffer(); const imageBytes = new TextEncoder().encode( '' + '' + '', ); let builderFrame = null; let builderCapture = null; let cameraFrame = null; let cameraCapture = null; try { builderFrame = await mountApp("/apps/dataset-builder/", "Dataset Builder"); const builderWindow = builderFrame.contentWindow; const builderDocument = builderFrame.contentDocument; builderCapture = captureDownloads(builderWindow); dispatchValue(builderWindow, builderDocument.querySelector("#scene-id"), "workflow-asset-scene"); dispatchValue(builderWindow, builderDocument.querySelector("#scene-name"), "Workflow asset scene"); dispatchValue(builderWindow, builderDocument.querySelector("#scene-duration"), duration); dispatchValue(builderWindow, builderDocument.querySelector("#source-duration"), duration); dispatchValue(builderWindow, builderDocument.querySelector("#source-z"), 1.6); dispatchValue(builderWindow, builderDocument.querySelector("#plane-x-min"), -0.3); dispatchValue(builderWindow, builderDocument.querySelector("#plane-x-max"), 0.5); dispatchValue(builderWindow, builderDocument.querySelector("#plane-y-min"), -0.25); dispatchValue(builderWindow, builderDocument.querySelector("#plane-y-max"), 0.35); dispatchValue(builderWindow, builderDocument.querySelector("#sample-rate"), sampleRate, "change"); const builderAudioFile = new builderWindow.File([audioBytes.slice(0)], "workflow-tone.wav", { type: "audio/wav", }); const builderImageFile = new builderWindow.File([imageBytes.slice(0)], "workflow-layout.svg", { type: "image/svg+xml", }); dispatchFiles(builderWindow, builderDocument.querySelector("#audio-input"), [builderAudioFile]); await waitFor( () => builderDocument.querySelectorAll(".asset-chip .asset-chip__state").length === 1 && builderDocument.querySelector(".asset-chip .asset-chip__state").textContent.includes("연결"), "Builder audio SHA attachment", ); dispatchFiles(builderWindow, builderDocument.querySelector("#image-input"), [builderImageFile]); await waitFor( () => builderDocument.querySelectorAll(".asset-chip .asset-chip__state").length === 2 && [...builderDocument.querySelectorAll(".asset-chip .asset-chip__state")] .every((element) => element.textContent.includes("연결")), "Builder image SHA attachment", ); builderDocument.querySelector("#add-source-button").click(); assertEqual(builderDocument.querySelectorAll(".source-card").length, 2, "two Builder sources"); dispatchValue(builderWindow, builderDocument.querySelector("#source-name"), "Asset source"); dispatchValue(builderWindow, builderDocument.querySelector("#source-x"), -0.1); dispatchValue(builderWindow, builderDocument.querySelector("#source-y"), -0.08); dispatchValue(builderWindow, builderDocument.querySelector("#source-type"), "asset", "change"); dispatchChecked(builderWindow, builderDocument.querySelector("#source-loop"), true); await waitFor( () => builderDocument.documentElement.dataset.sceneValid === "true" && builderDocument.documentElement.dataset.assetsReady === "true" && !builderDocument.querySelector("#export-button").disabled, "Builder asset Scene validation", ); builderDocument.querySelector("#export-button").click(); const sceneDownload = await waitFor( () => builderCapture.downloads.find((download) => download.fileName.endsWith(".scene.json")), "Builder Scene JSON export", ); const sceneJson = await fetch(sceneDownload.url).then((response) => response.text()); const scene = JSON.parse(sceneJson); const sceneIssues = await validateScene(scene); assertEqual(sceneIssues.length, 0, `Builder Scene validation: ${sceneIssues .map((issue) => `${issue.path}: ${issue.message}`) .join(" | ")}`); assertEqual(scene.sampleRate, sampleRate, "exported Scene sample rate"); assertEqual(scene.duration, duration, "exported Scene duration"); assertEqual(scene.sources.length, 2, "exported source count"); assert(scene.sources.some((source) => source.signal.type === "tone"), "exported tone source"); assert(scene.sources.some((source) => source.signal.type === "asset"), "exported asset source"); assertEqual(scene.assets.length, 2, "exported audio and image descriptors"); assertEqual(scene.image.plane.z, 1.6, "exported image plane z"); assertEqual(scene.image.plane.xMin, -0.3, "exported image plane xMin"); assertEqual(scene.image.plane.xMax, 0.5, "exported image plane xMax"); assert(scene.assets.every((asset) => /^[a-f0-9]{64}$/.test(asset.sha256)), "exported SHA manifests"); builderCapture.restore(); builderCapture = null; builderFrame.remove(); builderFrame = null; cameraFrame = await mountApp("/apps/acoustic-camera/", "Acoustic Camera"); const cameraWindow = cameraFrame.contentWindow; const cameraDocument = cameraFrame.contentDocument; cameraCapture = captureDownloads(cameraWindow); const sceneFile = new cameraWindow.File([sceneJson], "workflow.scene.json", { type: "application/json", }); dispatchFiles(cameraWindow, cameraDocument.querySelector("#scene-input"), [sceneFile]); await waitFor( () => cameraDocument.documentElement.dataset.inputBusy === "false" && cameraDocument.querySelector("#workspace-title").textContent === scene.name && cameraDocument.querySelectorAll("#asset-list .asset-status").length === 2, "Camera Scene import", ); assertEqual(cameraDocument.documentElement.dataset.cameraValid, "false", "assets required after import"); assertEqual(cameraDocument.querySelector("#scene-distance").valueAsNumber, scene.image.plane.z, "scan z follows Scene"); assertEqual(cameraDocument.querySelector("#scan-x-min").valueAsNumber, scene.image.plane.xMin, "scan xMin follows Scene"); assertEqual(cameraDocument.querySelector("#scan-x-max").valueAsNumber, scene.image.plane.xMax, "scan xMax follows Scene"); assertEqual(cameraDocument.querySelector("#scan-y-min").valueAsNumber, scene.image.plane.yMin, "scan yMin follows Scene"); assertEqual(cameraDocument.querySelector("#scan-y-max").valueAsNumber, scene.image.plane.yMax, "scan yMax follows Scene"); const experiment = makeExperiment(scene); const arrayDefinition = await fetch("/assets/array-geometries/rectangular-16x12.array.json") .then((response) => response.json()); const experimentIssues = await validateExperiment(experiment, { scene, arrayDefinition }); assertEqual(experimentIssues.length, 0, `workflow Experiment validation: ${experimentIssues .map((issue) => `${issue.path}: ${issue.message}`) .join(" | ")}`); const experimentFile = new cameraWindow.File( [JSON.stringify(experiment)], "workflow.experiment.json", { type: "application/json" }, ); dispatchFiles(cameraWindow, cameraDocument.querySelector("#experiment-input"), [experimentFile]); await waitFor( () => cameraDocument.documentElement.dataset.inputBusy === "false" && cameraDocument.querySelector("#experiment-id").value === experiment.id && cameraDocument.querySelector("#scan-columns").value === "8" && cameraDocument.querySelector("#backend").value === "cpu", "Camera Experiment import", ); const cameraAudioFile = new cameraWindow.File([audioBytes.slice(0)], "workflow-tone.wav", { type: "audio/wav", }); const cameraImageFile = new cameraWindow.File([imageBytes.slice(0)], "workflow-layout.svg", { type: "image/svg+xml", }); dispatchFiles(cameraWindow, cameraDocument.querySelector("#asset-input"), [cameraAudioFile, cameraImageFile]); await waitFor( () => cameraDocument.documentElement.dataset.inputBusy === "false" && cameraDocument.documentElement.dataset.cameraValid === "true" && cameraDocument.querySelectorAll("#asset-list .asset-status.is-ready").length === 2 && !cameraDocument.querySelector("#run-button").disabled, "Camera SHA asset rebind", ); cameraDocument.querySelector("#run-button").click(); await waitFor( () => cameraDocument.documentElement.dataset.runState === "done", "Camera CPU Worker run", 30000, ); assertEqual(cameraDocument.documentElement.dataset.runBackend, "cpu", "workflow backend"); assertEqual(cameraDocument.documentElement.dataset.resultBandCount, "2", "workflow result bands"); cameraDocument.querySelector("#export-result-button").click(); const resultDownload = await waitFor( () => cameraCapture.downloads.find((download) => download.fileName.endsWith(".result.json")), "Camera Result JSON export", ); const result = await fetch(resultDownload.url).then((response) => response.json()); const resultIssues = await validateResult(result, { scene, experiment }); assertEqual(resultIssues.length, 0, `workflow Result validation: ${resultIssues .map((issue) => `${issue.path}: ${issue.message}`) .join(" | ")}`); assertEqual(result.sceneId, scene.id, "Result references Builder Scene"); assertEqual(result.experimentId, experiment.id, "Result references imported Experiment"); assertEqual(result.backend, "cpu", "Result records CPU backend"); assertEqual(result.bands.length, 2, "Result records both bands"); assert(result.bands.every((band) => band.rawMap.rows === 8), "Result map rows"); assert(result.bands.every((band) => band.rawMap.columns === 8), "Result map columns"); assert(result.bands.every((band) => band.rawMap.values.length === 64), "Result map values"); } finally { cameraCapture?.restore(); builderCapture?.restore(); cameraFrame?.remove(); builderFrame?.remove(); } });