387 lines
14 KiB
JavaScript
387 lines
14 KiB
JavaScript
import {
|
||
MAX_MICROPHONES,
|
||
addMicrophone,
|
||
arrayGeometryIssues,
|
||
arrayStatistics,
|
||
centerMicrophones,
|
||
clampPositionToAperture,
|
||
createArrayDefinition,
|
||
fitApertureToMicrophones,
|
||
generatePresetMicrophones,
|
||
inferApertureShape,
|
||
moveMicrophone,
|
||
nextMicrophoneId,
|
||
positionInsideAperture,
|
||
removeMicrophone,
|
||
replaceMicrophonesWithPreset,
|
||
updateMicrophone,
|
||
} from "/apps/array-builder/model.js";
|
||
import { validateArrayDefinition } from "/packages/contracts/validation.js";
|
||
|
||
function assertNear(actual, expected, tolerance, label) {
|
||
assert(Math.abs(actual - expected) <= tolerance, label + ": expected " + expected + ", received " + actual);
|
||
}
|
||
|
||
function assertThrows(callback, text, label) {
|
||
let error;
|
||
try {
|
||
callback();
|
||
} catch (caught) {
|
||
error = caught;
|
||
}
|
||
assert(error instanceof Error, label + " should throw");
|
||
assert(error.message.includes(text), label + ": " + error.message);
|
||
}
|
||
|
||
test("Array Builder 기본 4x3 문서가 표준 Array 계약과 row-major 좌표를 만족한다", async () => {
|
||
const arrayDefinition = createArrayDefinition();
|
||
assertEqual(arrayDefinition.microphones.length, 12, "default microphone count");
|
||
assertEqual(arrayDefinition.microphones[0].id, "mic-001", "first channel id");
|
||
assertEqual(arrayDefinition.microphones[11].id, "mic-012", "last channel id");
|
||
assertEqual(arrayDefinition.microphones[0].position.join(","), "-0.225,-0.225,0", "bottom-left first");
|
||
assertEqual(arrayDefinition.microphones[3].position.join(","), "0.225,-0.225,0", "row advances left to right");
|
||
assertEqual(arrayDefinition.microphones[11].position.join(","), "0.225,0.225,0", "top-right last");
|
||
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "default array schema");
|
||
});
|
||
|
||
test("격자·선형·링·Fermat preset이 개수와 생성 경계를 지킨다", () => {
|
||
const aperture = { width: 0.4, height: 0.2 };
|
||
const grid = generatePresetMicrophones({ type: "grid", aperture, columns: 3, rows: 2 });
|
||
assertEqual(grid.length, 6, "grid count");
|
||
assertEqual(grid[0].position.join(","), "-0.2,-0.1,0", "grid first");
|
||
assertEqual(grid[5].position.join(","), "0.2,0.1,0", "grid last");
|
||
|
||
for (const type of ["linear", "ring", "spiral"]) {
|
||
const microphones = generatePresetMicrophones({
|
||
type,
|
||
aperture,
|
||
shape: "ellipse",
|
||
count: 17,
|
||
axis: "diagonal",
|
||
});
|
||
assertEqual(microphones.length, 17, type + " count");
|
||
assert(
|
||
microphones.every((microphone) => positionInsideAperture(microphone.position, aperture, "ellipse")),
|
||
type + " ellipse bounds",
|
||
);
|
||
}
|
||
});
|
||
|
||
test("원형과 Fermat preset은 비정사각 경계에서도 실제 내접원 좌표를 만든다", () => {
|
||
const aperture = { width: 0.5, height: 0.3 };
|
||
const edgeInsetM = 0.02;
|
||
const expectedRadius = 0.13;
|
||
const count = 12;
|
||
|
||
for (const type of ["ring", "spiral"]) {
|
||
const rectangle = generatePresetMicrophones({
|
||
type,
|
||
aperture,
|
||
shape: "rectangle",
|
||
edgeInsetM,
|
||
count,
|
||
});
|
||
const ellipse = generatePresetMicrophones({
|
||
type,
|
||
aperture,
|
||
shape: "ellipse",
|
||
edgeInsetM,
|
||
count,
|
||
});
|
||
assertEqual(
|
||
JSON.stringify(rectangle),
|
||
JSON.stringify(ellipse),
|
||
type + " coordinates describe the preset instead of tracing the boundary shape",
|
||
);
|
||
assert(
|
||
rectangle.every((microphone) =>
|
||
positionInsideAperture(microphone.position, aperture, "rectangle") &&
|
||
positionInsideAperture(microphone.position, aperture, "ellipse")),
|
||
type + " inscribed-circle coordinates fit either generation boundary",
|
||
);
|
||
}
|
||
|
||
const ring = generatePresetMicrophones({
|
||
type: "ring",
|
||
aperture,
|
||
shape: "rectangle",
|
||
edgeInsetM,
|
||
count,
|
||
});
|
||
for (const microphone of ring) {
|
||
assertNear(
|
||
Math.hypot(microphone.position[0], microphone.position[1]),
|
||
expectedRadius,
|
||
1e-8,
|
||
"ring radius",
|
||
);
|
||
}
|
||
|
||
const spiral = generatePresetMicrophones({
|
||
type: "spiral",
|
||
aperture,
|
||
shape: "rectangle",
|
||
edgeInsetM,
|
||
count,
|
||
});
|
||
spiral.forEach((microphone, index) => {
|
||
assertNear(
|
||
Math.hypot(microphone.position[0], microphone.position[1]),
|
||
expectedRadius * Math.sqrt((index + 0.5) / count),
|
||
1e-8,
|
||
"equal-area Fermat radius " + index,
|
||
);
|
||
});
|
||
assertEqual(
|
||
generatePresetMicrophones({ type: "spiral", aperture, count: 1 })[0].position.join(","),
|
||
"0,0,0",
|
||
"single-microphone spiral is centered",
|
||
);
|
||
});
|
||
|
||
test("Archimedean preset은 중심부터 내접원까지 선형 반경의 실제 나선을 만든다", () => {
|
||
const aperture = { width: 0.5, height: 0.3 };
|
||
const edgeInsetM = 0.02;
|
||
const maximumRadius = 0.13;
|
||
const turns = 2.5;
|
||
const count = 11;
|
||
const microphones = generatePresetMicrophones({
|
||
type: "archimedean",
|
||
aperture,
|
||
shape: "rectangle",
|
||
edgeInsetM,
|
||
turns,
|
||
count,
|
||
});
|
||
assertEqual(microphones.length, count, "Archimedean count");
|
||
microphones.forEach((microphone, index) => {
|
||
assertNear(
|
||
Math.hypot(microphone.position[0], microphone.position[1]),
|
||
maximumRadius * index / (count - 1),
|
||
1e-8,
|
||
"Archimedean linear radius " + index,
|
||
);
|
||
});
|
||
assertEqual(microphones[0].position.join(","), "0,0,0", "spiral starts at center");
|
||
assertNear(microphones.at(-1).position[0], -maximumRadius, 1e-8, "2.5 turns end at -x");
|
||
assertNear(microphones.at(-1).position[1], 0, 1e-8, "2.5 turns end on x axis");
|
||
assertThrows(
|
||
() => generatePresetMicrophones({
|
||
type: "archimedean",
|
||
aperture,
|
||
turns: 0,
|
||
count,
|
||
}),
|
||
"0.25",
|
||
"Archimedean turn lower bound",
|
||
);
|
||
});
|
||
|
||
test("타원 격자는 요청한 rows x columns 수를 유지하며 경계 안에 배치된다", () => {
|
||
const aperture = { width: 0.45, height: 0.3 };
|
||
const microphones = generatePresetMicrophones({
|
||
type: "grid",
|
||
aperture,
|
||
shape: "ellipse",
|
||
columns: 7,
|
||
rows: 5,
|
||
});
|
||
assertEqual(microphones.length, 35, "ellipse grid count");
|
||
assert(
|
||
microphones.every((microphone) => positionInsideAperture(microphone.position, aperture, "ellipse")),
|
||
"ellipse-adapted grid bounds",
|
||
);
|
||
});
|
||
|
||
test("seed random preset은 재현되고 최소 간격과 형상 경계를 지킨다", () => {
|
||
const options = {
|
||
type: "random",
|
||
aperture: { width: 0.5, height: 0.35 },
|
||
shape: "ellipse",
|
||
count: 24,
|
||
seed: 12345,
|
||
minimumSpacingM: 0.01,
|
||
};
|
||
const first = generatePresetMicrophones(options);
|
||
const repeated = generatePresetMicrophones(options);
|
||
const different = generatePresetMicrophones({ ...options, seed: 54321 });
|
||
assertEqual(JSON.stringify(first), JSON.stringify(repeated), "same seed layout");
|
||
assert(JSON.stringify(first) !== JSON.stringify(different), "different seed layout");
|
||
assert(
|
||
first.every((microphone) => positionInsideAperture(microphone.position, options.aperture, "ellipse")),
|
||
"random ellipse bounds",
|
||
);
|
||
for (let left = 0; left < first.length; left += 1) {
|
||
for (let right = left + 1; right < first.length; right += 1) {
|
||
const distance = Math.hypot(
|
||
first[left].position[0] - first[right].position[0],
|
||
first[left].position[1] - first[right].position[1],
|
||
);
|
||
assert(distance >= 0.01 - 1e-9, "random minimum spacing");
|
||
}
|
||
}
|
||
});
|
||
|
||
test("1,024채널 random preset은 공간 index로 고유 좌표를 빠르게 생성한다", () => {
|
||
const startedAt = performance.now();
|
||
const microphones = generatePresetMicrophones({
|
||
type: "random",
|
||
aperture: { width: 0.45, height: 0.45 },
|
||
shape: "rectangle",
|
||
count: 1024,
|
||
seed: 20260828,
|
||
minimumSpacingM: 0.002,
|
||
});
|
||
const elapsedMs = performance.now() - startedAt;
|
||
assertEqual(microphones.length, 1024, "maximum random channel count");
|
||
assertEqual(
|
||
new Set(microphones.map((microphone) => microphone.position.join(","))).size,
|
||
1024,
|
||
"maximum random positions are unique",
|
||
);
|
||
assert(elapsedMs < 3000, "maximum random generation stays interactive: " + elapsedMs + " ms");
|
||
|
||
const failedAt = performance.now();
|
||
assertThrows(
|
||
() => generatePresetMicrophones({
|
||
type: "random",
|
||
aperture: { width: 0.45, height: 0.45 },
|
||
shape: "rectangle",
|
||
count: 1024,
|
||
seed: 7,
|
||
minimumSpacingM: 0.03,
|
||
}),
|
||
"배치할 수 없습니다",
|
||
"dense maximum random failure",
|
||
);
|
||
const failedElapsedMs = performance.now() - failedAt;
|
||
assert(
|
||
failedElapsedMs < 3000,
|
||
"impossible maximum random layout fails within a bound: " + failedElapsedMs + " ms",
|
||
);
|
||
});
|
||
|
||
test("불가능한 random 조건과 최대 채널 초과는 기존 배열을 보존한다", () => {
|
||
const arrayDefinition = createArrayDefinition();
|
||
const before = JSON.stringify(arrayDefinition.microphones);
|
||
assertThrows(
|
||
() => replaceMicrophonesWithPreset(arrayDefinition, {
|
||
type: "random",
|
||
aperture: arrayDefinition.aperture,
|
||
count: 100,
|
||
minimumSpacingM: 0.4,
|
||
seed: 1,
|
||
}),
|
||
"배치할 수 없습니다",
|
||
"impossible random spacing",
|
||
);
|
||
assertEqual(JSON.stringify(arrayDefinition.microphones), before, "failed generation is atomic");
|
||
assertThrows(
|
||
() => generatePresetMicrophones({
|
||
type: "linear",
|
||
aperture: arrayDefinition.aperture,
|
||
count: MAX_MICROPHONES + 1,
|
||
}),
|
||
"1–1024",
|
||
"microphone maximum",
|
||
);
|
||
});
|
||
|
||
test("수동 add/update/reorder/remove가 ID와 calibration을 보존한다", () => {
|
||
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 });
|
||
arrayDefinition.microphones[0].sensitivityDb = -1.5;
|
||
arrayDefinition.microphones[0].phaseOffsetDeg = 8;
|
||
arrayDefinition.microphones[0].frequencyResponse = [
|
||
{ frequencyHz: 100, gainDb: -2 },
|
||
{ frequencyHz: 1000, gainDb: 0.5, phaseDeg: 2 },
|
||
];
|
||
updateMicrophone(arrayDefinition, "mic-001", { position: [-0.1, 0.02, 0.003] });
|
||
assertEqual(arrayDefinition.microphones[0].sensitivityDb, -1.5, "sensitivity preserved");
|
||
assertEqual(arrayDefinition.microphones[0].frequencyResponse.length, 2, "response preserved");
|
||
const added = addMicrophone(arrayDefinition, [0, 0, 0]);
|
||
assertEqual(added.id, "mic-003", "stable next id");
|
||
assert(moveMicrophone(arrayDefinition, added.id, -1), "channel reordered");
|
||
assertEqual(arrayDefinition.microphones[1].id, "mic-003", "new channel order");
|
||
assert(removeMicrophone(arrayDefinition, "mic-002"), "channel removed");
|
||
assertEqual(addMicrophone(arrayDefinition, [0.05, 0, 0]).id, "mic-004", "deleted suffix is not reused");
|
||
while (arrayDefinition.microphones.length > 1) {
|
||
removeMicrophone(arrayDefinition, arrayDefinition.microphones.at(-1).id);
|
||
}
|
||
assertEqual(removeMicrophone(arrayDefinition, arrayDefinition.microphones[0].id), false, "last microphone retained");
|
||
});
|
||
|
||
test("Builder geometry 진단은 중복을 오류, 생성 경계 초과를 경고로 구분한다", () => {
|
||
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 });
|
||
arrayDefinition.microphones[0].position[0] = 0.4;
|
||
arrayDefinition.microphones[1].position = [...arrayDefinition.microphones[0].position];
|
||
const issues = arrayGeometryIssues(arrayDefinition, { shape: "ellipse" });
|
||
assert(issues.some((issue) => issue.severity === "error" && issue.message.includes("중복")), "duplicate error");
|
||
assert(issues.some((issue) => issue.severity === "warning" && issue.message.includes("경계 밖")), "bounds warning");
|
||
});
|
||
|
||
test("좌표 clamp, 중심 정렬과 aperture 맞춤이 명시적으로 동작한다", () => {
|
||
const aperture = { width: 0.4, height: 0.2 };
|
||
const clamped = clampPositionToAperture([0.4, 0.2, 0.03], aperture, "ellipse");
|
||
assert(positionInsideAperture(clamped, aperture, "ellipse"), "ellipse clamp");
|
||
assertNear(clamped[2], 0.03, 1e-12, "z remains editable");
|
||
|
||
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1, width: 0.2, height: 0.1 });
|
||
arrayDefinition.microphones[0].position = [0.2, 0.1, 0.02];
|
||
arrayDefinition.microphones[1].position = [0.4, 0.1, 0.04];
|
||
centerMicrophones(arrayDefinition);
|
||
assertEqual(arrayDefinition.microphones[0].position.join(","), "-0.1,0,-0.01", "center first");
|
||
assertEqual(arrayDefinition.microphones[1].position.join(","), "0.1,0,0.01", "center second");
|
||
fitApertureToMicrophones(arrayDefinition, { shape: "rectangle", paddingM: 0.01 });
|
||
assertNear(arrayDefinition.aperture.width, 0.22, 1e-12, "fitted width");
|
||
assertNear(arrayDefinition.aperture.height, 0.021, 1e-12, "fitted height");
|
||
});
|
||
|
||
test("문서의 명시적 형상 단서만으로 타원 생성 경계를 추론한다", () => {
|
||
const spiral = createArrayDefinition({
|
||
description: "Fermat spiral on circular aperture",
|
||
presetType: "spiral",
|
||
count: 32,
|
||
});
|
||
assertEqual(inferApertureShape(spiral), "ellipse", "description inference");
|
||
const grid = createArrayDefinition();
|
||
assertEqual(inferApertureShape(grid), "rectangle", "grid inference");
|
||
grid.description = "Spring-mounted rectangular array";
|
||
assertEqual(inferApertureShape(grid), "rectangle", "word boundary avoids spring false positive");
|
||
});
|
||
|
||
test("매우 큰 유한 좌표에서도 배열 중심 통계가 유한하게 유지된다", () => {
|
||
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 });
|
||
arrayDefinition.microphones[0].position = [Number.MAX_VALUE, Number.MAX_VALUE, 0];
|
||
arrayDefinition.microphones[1].position = [Number.MAX_VALUE, -Number.MAX_VALUE, 0];
|
||
const statistics = arrayStatistics(arrayDefinition);
|
||
assert(Number.isFinite(statistics.centroid[0]), "large x centroid remains finite");
|
||
assert(Number.isFinite(statistics.centroid[1]), "large y centroid remains finite");
|
||
assertEqual(statistics.centroid[0], Number.MAX_VALUE, "large x centroid");
|
||
assertEqual(statistics.centroid[1], 0, "balanced y centroid");
|
||
});
|
||
|
||
test("큰 숫자 suffix ID도 마이크 추가를 멈추지 않고 정확히 증가시킨다", () => {
|
||
const arrayDefinition = createArrayDefinition({ columns: 1, rows: 1 });
|
||
arrayDefinition.microphones[0].id = "mic-9007199254740992";
|
||
assertEqual(
|
||
nextMicrophoneId(arrayDefinition),
|
||
"mic-9007199254740993",
|
||
"BigInt suffix increment",
|
||
);
|
||
assertEqual(
|
||
addMicrophone(arrayDefinition, [0.01, 0, 0]).id,
|
||
"mic-9007199254740993",
|
||
"manual add uses the safe ID",
|
||
);
|
||
});
|
||
|
||
test("고정밀 import 좌표는 실제 값이 다르면 중복으로 오인하지 않는다", () => {
|
||
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 });
|
||
arrayDefinition.microphones[0].position = [0.0000000001, 0, 0];
|
||
arrayDefinition.microphones[1].position = [0.0000000002, 0, 0];
|
||
assert(
|
||
!arrayGeometryIssues(arrayDefinition).some((issue) => issue.severity === "error"),
|
||
"distinct high-precision positions",
|
||
);
|
||
});
|