286 lines
14 KiB
JavaScript
286 lines
14 KiB
JavaScript
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(
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240">' +
|
|
'<rect width="320" height="240" fill="#102924"/>' +
|
|
'<circle cx="210" cy="95" r="35" fill="#65f2bf"/></svg>',
|
|
);
|
|
|
|
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();
|
|
}
|
|
});
|