feat: add interactive microphone array builder
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
import { validateArrayDefinition } from "/packages/contracts/validation.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
label + ": expected " + expected + ", received " + actual,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitFor(predicate, label, timeoutMs = 20000) {
|
||||
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 + " 대기 시간이 초과되었습니다.");
|
||||
}
|
||||
|
||||
async function nextPaint(windowObject) {
|
||||
await new Promise((resolve) => windowObject.requestAnimationFrame(() =>
|
||||
windowObject.requestAnimationFrame(resolve)));
|
||||
}
|
||||
|
||||
function dispatchValue(windowObject, element, value, eventName = "change") {
|
||||
element.value = String(value);
|
||||
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
|
||||
}
|
||||
|
||||
function dispatchFiles(windowObject, input, files) {
|
||||
const transfer = new windowObject.DataTransfer();
|
||||
for (const file of files) transfer.items.add(file);
|
||||
input.files = transfer.files;
|
||||
input.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
function microphoneButtons(documentObject) {
|
||||
return [...documentObject.querySelectorAll("#microphone-list [data-microphone-id]")];
|
||||
}
|
||||
|
||||
function microphoneCount(documentObject) {
|
||||
return microphoneButtons(documentObject).length;
|
||||
}
|
||||
|
||||
function selectedCoordinates(documentObject) {
|
||||
return ["microphone-x", "microphone-y", "microphone-z"].map(
|
||||
(id) => Number(documentObject.getElementById(id).value),
|
||||
);
|
||||
}
|
||||
|
||||
test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을 완주한다", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.src = "/apps/array-builder/?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("Array Builder iframe load failed")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
const windowObject = frame.contentWindow;
|
||||
const documentObject = frame.contentDocument;
|
||||
const downloads = [];
|
||||
const originalCreate = windowObject.URL.createObjectURL.bind(windowObject.URL);
|
||||
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
|
||||
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
|
||||
windowObject.URL.createObjectURL = (blob) => {
|
||||
const url = "blob:array-builder-test-" + String(downloads.length + 1);
|
||||
downloads.push({ blob, url, fileName: null });
|
||||
return url;
|
||||
};
|
||||
windowObject.URL.revokeObjectURL = () => {};
|
||||
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
|
||||
const download = downloads.find((item) => item.url === this.href);
|
||||
if (download) download.fileName = this.download;
|
||||
};
|
||||
|
||||
try {
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.appReady === "true",
|
||||
"Array Builder 초기화",
|
||||
);
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.arrayValid === "true",
|
||||
"기본 Array 검증",
|
||||
);
|
||||
await waitFor(
|
||||
() => documentObject.querySelector("#array-canvas").width > 0,
|
||||
"Array canvas 렌더",
|
||||
);
|
||||
assertEqual(microphoneCount(documentObject), 12, "default 4x3 channels");
|
||||
assertEqual(
|
||||
documentObject.querySelector("#microphone-list [data-microphone-id]").dataset.microphoneId,
|
||||
"mic-001",
|
||||
"physical channel order starts at mic-001",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 500,
|
||||
"canvas dominates the desktop workspace",
|
||||
);
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-columns"), 3);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-rows"), 2);
|
||||
documentObject.querySelector("#generate-preset-button").click();
|
||||
await waitFor(() => microphoneCount(documentObject) === 6, "3x2 grid generation");
|
||||
assertEqual(
|
||||
microphoneButtons(documentObject).map((button) => button.dataset.microphoneId).join(","),
|
||||
"mic-001,mic-002,mic-003,mic-004,mic-005,mic-006",
|
||||
"grid keeps row-major physical channel IDs",
|
||||
);
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#aperture-shape"), "ellipse");
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "ring");
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-count"), 8);
|
||||
documentObject.querySelector("#generate-preset-button").click();
|
||||
await waitFor(() => microphoneCount(documentObject) === 8, "ellipse ring generation");
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.arrayValid === "true",
|
||||
"ellipse ring validation",
|
||||
);
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "random");
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-count"), 12);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-seed"), 4242);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-minimum-spacing"), 0.01);
|
||||
documentObject.querySelector("#generate-preset-button").click();
|
||||
await waitFor(() => microphoneCount(documentObject) === 12, "seed random generation");
|
||||
const firstRandom = documentObject.querySelector("#microphone-list").textContent;
|
||||
documentObject.querySelector("#generate-preset-button").click();
|
||||
const repeatedRandom = documentObject.querySelector("#microphone-list").textContent;
|
||||
assertEqual(repeatedRandom, firstRandom, "same seed repeats the visible layout");
|
||||
|
||||
documentObject.querySelector("#undo-button").click();
|
||||
assertEqual(microphoneCount(documentObject), 8, "preset replacement is undoable");
|
||||
documentObject.querySelector("#redo-button").click();
|
||||
assertEqual(microphoneCount(documentObject), 12, "preset replacement is redoable");
|
||||
|
||||
documentObject.querySelector("#add-microphone-button").click();
|
||||
await waitFor(() => microphoneCount(documentObject) === 13, "manual microphone add");
|
||||
assertEqual(
|
||||
documentObject.querySelector("#microphone-id").value,
|
||||
"mic-013",
|
||||
"manual add selects a stable next ID",
|
||||
);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#microphone-id"), "reference-channel");
|
||||
dispatchValue(windowObject, documentObject.querySelector("#microphone-x"), 0.01);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#microphone-y"), -0.015);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#microphone-z"), 0.002);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#microphone-sensitivity"), -2.5);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#microphone-phase"), 3.5);
|
||||
assertEqual(
|
||||
documentObject.querySelector("#microphone-id").value,
|
||||
"reference-channel",
|
||||
"manual ID edit",
|
||||
);
|
||||
|
||||
const beforeNudge = selectedCoordinates(documentObject);
|
||||
const canvas = documentObject.querySelector("#array-canvas");
|
||||
canvas.focus();
|
||||
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: "ArrowRight",
|
||||
}));
|
||||
await nextPaint(windowObject);
|
||||
const afterNudge = selectedCoordinates(documentObject);
|
||||
assertNear(afterNudge[0], beforeNudge[0] + 0.005, 1e-9, "keyboard nudge uses snap step");
|
||||
assertNear(afterNudge[1], beforeNudge[1], 1e-9, "keyboard nudge keeps y");
|
||||
|
||||
documentObject.querySelector("#add-tool-button").click();
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: 31,
|
||||
pointerType: "mouse",
|
||||
isPrimary: true,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
clientX: canvasRect.left + canvasRect.width * 0.7,
|
||||
clientY: canvasRect.top + canvasRect.height * 0.35,
|
||||
}));
|
||||
await waitFor(() => microphoneCount(documentObject) === 14, "canvas microphone add");
|
||||
documentObject.querySelector("#select-tool-button").click();
|
||||
documentObject.querySelector("#remove-microphone-button").click();
|
||||
await waitFor(() => microphoneCount(documentObject) === 13, "manual delete");
|
||||
|
||||
const firstButton = microphoneButtons(documentObject)[0];
|
||||
const secondButton = microphoneButtons(documentObject)[1];
|
||||
firstButton.click();
|
||||
const firstPosition = selectedCoordinates(documentObject);
|
||||
secondButton.click();
|
||||
dispatchValue(windowObject, documentObject.querySelector("#microphone-x"), firstPosition[0]);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#microphone-y"), firstPosition[1]);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#microphone-z"), firstPosition[2]);
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.arrayValid === "false",
|
||||
"duplicate position rejection",
|
||||
);
|
||||
assert(documentObject.querySelector("#export-button").disabled, "invalid duplicate disables export");
|
||||
documentObject.querySelector("#undo-button").click();
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.arrayValid === "true",
|
||||
"duplicate undo recovery",
|
||||
);
|
||||
|
||||
documentObject.querySelector("#export-button").click();
|
||||
await waitFor(() => downloads.some((item) => item.fileName), "Array JSON export");
|
||||
const exportedDownload = downloads.find((item) => item.fileName);
|
||||
assert(
|
||||
exportedDownload.fileName.endsWith(".array.json"),
|
||||
"export uses .array.json filename",
|
||||
);
|
||||
const exported = JSON.parse(await exportedDownload.blob.text());
|
||||
assertEqual(exported.kind, "micarrsim.array", "standard array kind");
|
||||
assertEqual((await validateArrayDefinition(exported)).length, 0, "exported Array contract");
|
||||
assert(!Object.hasOwn(exported, "shape"), "editor shape is not exported");
|
||||
assert(!Object.hasOwn(exported, "preset"), "editor preset is not exported");
|
||||
assert(!Object.hasOwn(exported, "seed"), "editor seed is not exported");
|
||||
const calibrated = exported.microphones.find(
|
||||
(microphone) => microphone.id === "reference-channel",
|
||||
);
|
||||
assert(calibrated, "edited channel is exported");
|
||||
assertEqual(calibrated.sensitivityDb, -2.5, "sensitivity is preserved");
|
||||
assertEqual(calibrated.phaseOffsetDeg, 3.5, "phase is preserved");
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#array-name"), "Temporary name");
|
||||
const exportedFile = new windowObject.File(
|
||||
[JSON.stringify(exported)],
|
||||
"round-trip.array.json",
|
||||
{ type: "application/json" },
|
||||
);
|
||||
dispatchFiles(windowObject, documentObject.querySelector("#array-input"), [exportedFile]);
|
||||
await waitFor(
|
||||
() => documentObject.querySelector("#array-name").value === exported.name,
|
||||
"Array round trip import",
|
||||
);
|
||||
microphoneButtons(documentObject).find(
|
||||
(button) => button.dataset.microphoneId === "reference-channel",
|
||||
).click();
|
||||
assertEqual(
|
||||
Number(documentObject.querySelector("#microphone-sensitivity").value),
|
||||
-2.5,
|
||||
"import keeps sensitivity",
|
||||
);
|
||||
|
||||
const countBeforeInvalidImport = microphoneCount(documentObject);
|
||||
const invalidFile = new windowObject.File(
|
||||
[JSON.stringify({ kind: "not-an-array" })],
|
||||
"invalid.json",
|
||||
{ type: "application/json" },
|
||||
);
|
||||
dispatchFiles(windowObject, documentObject.querySelector("#array-input"), [invalidFile]);
|
||||
await waitFor(
|
||||
() => documentObject.querySelector("#validation-title").textContent.includes("적용하지 못"),
|
||||
"invalid import feedback",
|
||||
);
|
||||
assertEqual(
|
||||
microphoneCount(documentObject),
|
||||
countBeforeInvalidImport,
|
||||
"invalid import preserves current array",
|
||||
);
|
||||
|
||||
frame.style.width = "390px";
|
||||
await waitFor(
|
||||
() => documentObject.querySelector("#array-builder-layout").dataset.splitPaneActive === "false",
|
||||
"compact Array Builder layout",
|
||||
);
|
||||
await nextPaint(windowObject);
|
||||
assert(
|
||||
documentObject.documentElement.scrollWidth <=
|
||||
documentObject.documentElement.clientWidth + 1,
|
||||
"Array Builder has no horizontal overflow at 390px",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
|
||||
"Array canvas remains usable at 390px",
|
||||
);
|
||||
} finally {
|
||||
windowObject.URL.createObjectURL = originalCreate;
|
||||
windowObject.URL.revokeObjectURL = originalRevoke;
|
||||
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||||
frame.remove();
|
||||
}
|
||||
});
|
||||
@@ -20,9 +20,11 @@ try {
|
||||
await import("/tests/unit/webgpu.test.js");
|
||||
await import("/tests/unit/split-pane.test.js");
|
||||
await import("/tests/unit/array-builder.test.js");
|
||||
await import("/tests/unit/array-builder-canvas.test.js");
|
||||
await import("/tests/integration/cpu-baseline.test.js");
|
||||
await import("/tests/integration/webgpu.test.js");
|
||||
await import("/tests/integration/server.test.js");
|
||||
await import("/tests/integration/array-builder-ui.test.js");
|
||||
await import("/tests/integration/split-layout-ui.test.js");
|
||||
await import("/tests/integration/builder-ui.test.js");
|
||||
await import("/tests/integration/camera-ui.test.js");
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
arrayPositionToCanvas,
|
||||
canvasPointToArray,
|
||||
eventToArrayPosition,
|
||||
eventToCanvasPoint,
|
||||
fitArrayViewport,
|
||||
hitTestMicrophone,
|
||||
keyboardNudgeForKey,
|
||||
niceGridStep,
|
||||
} from "/apps/array-builder/canvas-view.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(Math.abs(actual - expected) <= tolerance, label + ": expected " + expected + ", received " + actual);
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
return {
|
||||
aperture: { width: 0.4, height: 0.2 },
|
||||
microphones: [
|
||||
{ id: "mic-001", position: [-0.1, -0.05, 0] },
|
||||
{ id: "mic-002", position: [0.1, 0.05, 0.01] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test("Array canvas fit은 x/y에 동일 축척을 사용하고 좌표를 왕복한다", () => {
|
||||
const viewport = fitArrayViewport(800, 420, fixture());
|
||||
const point = [0.075, -0.025, 0.012];
|
||||
const pixel = arrayPositionToCanvas(point, viewport);
|
||||
const roundTrip = canvasPointToArray(pixel, viewport, { z: point[2] });
|
||||
assertNear(roundTrip[0], point[0], 1e-12, "x round trip");
|
||||
assertNear(roundTrip[1], point[1], 1e-12, "y round trip");
|
||||
assertNear(roundTrip[2], point[2], 1e-12, "z preserved");
|
||||
const oneCentimeterX = arrayPositionToCanvas([0.01, 0, 0], viewport).x -
|
||||
arrayPositionToCanvas([0, 0, 0], viewport).x;
|
||||
const oneCentimeterY = arrayPositionToCanvas([0, 0.01, 0], viewport).y -
|
||||
arrayPositionToCanvas([0, 0, 0], viewport).y;
|
||||
assertNear(Math.abs(oneCentimeterX), Math.abs(oneCentimeterY), 1e-12, "equal x/y scale");
|
||||
});
|
||||
|
||||
test("Array canvas 좌표는 화면 위쪽을 양의 y로 해석한다", () => {
|
||||
const viewport = fitArrayViewport(500, 500, fixture());
|
||||
const upper = canvasPointToArray(
|
||||
{ x: viewport.plot.x + viewport.plot.width / 2, y: viewport.plot.y },
|
||||
viewport,
|
||||
);
|
||||
const lower = canvasPointToArray(
|
||||
{ x: viewport.plot.x + viewport.plot.width / 2, y: viewport.plot.y + viewport.plot.height },
|
||||
viewport,
|
||||
);
|
||||
assert(upper[1] > lower[1], "canvas top maps to larger array y");
|
||||
assertNear(upper[1], viewport.yMax, 1e-12, "top is yMax");
|
||||
assertNear(lower[1], viewport.yMin, 1e-12, "bottom is yMin");
|
||||
});
|
||||
|
||||
test("PointerEvent-like 좌표 변환은 DOM 없이 canvas와 plot 경계에 clamp할 수 있다", () => {
|
||||
const viewport = fitArrayViewport(640, 360, fixture());
|
||||
const rect = { left: 100, top: 50, width: 640, height: 360 };
|
||||
const canvasPoint = eventToCanvasPoint({ clientX: 50, clientY: 500 }, rect, { clamp: true });
|
||||
assertEqual(canvasPoint.x, 0, "client x clamps to canvas left");
|
||||
assertEqual(canvasPoint.y, 360, "client y clamps to canvas bottom");
|
||||
const position = eventToArrayPosition(
|
||||
{ clientX: -500, clientY: 900 },
|
||||
rect,
|
||||
viewport,
|
||||
{ clamp: true, clampToCanvas: true, z: 0.02 },
|
||||
);
|
||||
assertNear(position[0], viewport.xMin, 1e-12, "event x clamps to plot xMin");
|
||||
assertNear(position[1], viewport.yMin, 1e-12, "event y clamps to plot yMin");
|
||||
assertEqual(position[2], 0.02, "event conversion carries z");
|
||||
});
|
||||
|
||||
test("Array canvas hit test는 반경 안의 가장 가까운 마이크와 채널 index를 돌려준다", () => {
|
||||
const arrayDefinition = fixture();
|
||||
const viewport = fitArrayViewport(800, 420, arrayDefinition);
|
||||
const micPixel = arrayPositionToCanvas(arrayDefinition.microphones[1].position, viewport);
|
||||
const hit = hitTestMicrophone(
|
||||
{ x: micPixel.x + 3, y: micPixel.y - 2 },
|
||||
arrayDefinition.microphones,
|
||||
viewport,
|
||||
8,
|
||||
);
|
||||
assertEqual(hit.id, "mic-002", "closest microphone id");
|
||||
assertEqual(hit.index, 1, "physical channel index");
|
||||
assertNear(hit.distancePx, Math.hypot(3, 2), 1e-12, "pixel distance");
|
||||
assertEqual(hitTestMicrophone({ x: 0, y: 0 }, arrayDefinition.microphones, viewport, 2), null, "miss");
|
||||
});
|
||||
|
||||
test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규칙을 따른다", () => {
|
||||
const candidates = [niceGridStep(0.008), niceGridStep(0.08), niceGridStep(0.8), niceGridStep(8)];
|
||||
for (const step of candidates) {
|
||||
const magnitude = 10 ** Math.floor(Math.log10(step));
|
||||
const normalized = Math.round(step / magnitude);
|
||||
assert([1, 2, 5, 10].includes(normalized), "grid step uses 1/2/5 decade");
|
||||
}
|
||||
assertEqual(keyboardNudgeForKey("ArrowLeft", 0.002).dx, -0.002, "left nudge");
|
||||
assertEqual(keyboardNudgeForKey("ArrowUp", 0.002).dy, 0.002, "up is positive y");
|
||||
assertEqual(keyboardNudgeForKey("ArrowDown", 0.002, true).dy, -0.02, "shift multiplies by ten");
|
||||
assertEqual(keyboardNudgeForKey("Enter", 0.002), null, "non-arrow key ignored");
|
||||
});
|
||||
Reference in New Issue
Block a user