feat: add microphone array builder model

This commit is contained in:
2026-08-28 22:00:57 +09:00
parent 33618be129
commit acf68f4931
3 changed files with 736 additions and 0 deletions
+538
View File
@@ -0,0 +1,538 @@
export const MAX_MICROPHONES = 1024;
export const APERTURE_SHAPES = Object.freeze(["rectangle", "ellipse"]);
export const PRESET_TYPES = Object.freeze(["grid", "linear", "ring", "spiral", "random"]);
const COORDINATE_SYSTEM = Object.freeze({
handedness: "right",
xAxis: "right",
yAxis: "up",
zAxis: "toward-scene",
});
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const COORDINATE_DIGITS = 9;
function finiteNumber(value, label) {
const number = Number(value);
if (!Number.isFinite(number)) throw new TypeError(label + "은(는) 유한한 숫자여야 합니다.");
return number;
}
function positiveDimension(value, label) {
const number = finiteNumber(value, label);
if (number <= 0 || number > 20) throw new RangeError(label + "은(는) 0보다 크고 20 m 이하여야 합니다.");
return number;
}
function boundedInteger(value, label, minimum, maximum) {
const number = Number(value);
if (!Number.isInteger(number) || number < minimum || number > maximum) {
throw new RangeError(label + "은(는) " + minimum + "–" + maximum + " 정수여야 합니다.");
}
return number;
}
function normalizedShape(shape) {
return APERTURE_SHAPES.includes(shape) ? shape : "rectangle";
}
function rounded(value) {
const number = Number(finiteNumber(value, "좌표").toFixed(COORDINATE_DIGITS));
return Object.is(number, -0) ? 0 : number;
}
function normalizedPosition(position) {
if (!Array.isArray(position) || position.length !== 3) {
throw new TypeError("마이크 위치는 [x, y, z] 숫자 3개여야 합니다.");
}
return position.map(rounded);
}
function normalizedAperture(aperture) {
return {
width: positiveDimension(aperture?.width, "aperture width"),
height: positiveDimension(aperture?.height, "aperture height"),
};
}
function microphoneId(index) {
return "mic-" + String(index).padStart(3, "0");
}
function microphoneFromPosition(position, index) {
return { id: microphoneId(index + 1), position: normalizedPosition(position) };
}
function pointKey(position) {
return position.map((value) => rounded(value).toFixed(COORDINATE_DIGITS)).join(",");
}
function linearValue(index, count, minimum, maximum) {
if (count === 1) return (minimum + maximum) / 2;
return minimum + (index / (count - 1)) * (maximum - minimum);
}
function presetBounds(aperture, edgeInsetM) {
const dimensions = normalizedAperture(aperture);
const inset = finiteNumber(edgeInsetM ?? 0, "경계 여백");
if (inset < 0 || inset * 2 >= Math.min(dimensions.width, dimensions.height)) {
throw new RangeError("경계 여백은 0 이상이고 짧은 aperture 변의 절반보다 작아야 합니다.");
}
return {
width: dimensions.width,
height: dimensions.height,
halfWidth: dimensions.width / 2 - inset,
halfHeight: dimensions.height / 2 - inset,
};
}
function gridPositions(options, bounds, shape) {
const columns = boundedInteger(options.columns ?? 4, "Columns", 1, MAX_MICROPHONES);
const rows = boundedInteger(options.rows ?? 3, "Rows", 1, MAX_MICROPHONES);
if (columns * rows > MAX_MICROPHONES) {
throw new RangeError("격자 마이크는 최대 " + MAX_MICROPHONES + "개까지 생성할 수 있습니다.");
}
const positions = [];
for (let row = 0; row < rows; row += 1) {
if (shape === "ellipse") {
const normalizedY = rows === 1 ? 0 : -1 + (2 * (row + 0.5)) / rows;
const y = normalizedY * bounds.halfHeight;
const rowHalfWidth = bounds.halfWidth * Math.sqrt(Math.max(0, 1 - normalizedY * normalizedY));
for (let column = 0; column < columns; column += 1) {
const normalizedX = columns === 1 ? 0 : -1 + (2 * (column + 0.5)) / columns;
positions.push([normalizedX * rowHalfWidth, y, 0]);
}
} else {
const y = linearValue(row, rows, -bounds.halfHeight, bounds.halfHeight);
for (let column = 0; column < columns; column += 1) {
positions.push([
linearValue(column, columns, -bounds.halfWidth, bounds.halfWidth),
y,
0,
]);
}
}
}
return positions;
}
function linearPositions(options, bounds, shape) {
const count = boundedInteger(options.count ?? 8, "마이크 수", 1, MAX_MICROPHONES);
const axis = ["x", "y", "diagonal"].includes(options.axis) ? options.axis : "x";
return Array.from({ length: count }, (_, index) => {
const ratio = count === 1 ? 0.5 : index / (count - 1);
if (axis === "y") return [0, -bounds.halfHeight + ratio * bounds.halfHeight * 2, 0];
if (axis === "diagonal") {
const extent = shape === "ellipse" ? Math.SQRT1_2 : 1;
return [
(-bounds.halfWidth + ratio * bounds.halfWidth * 2) * extent,
(-bounds.halfHeight + ratio * bounds.halfHeight * 2) * extent,
0,
];
}
return [-bounds.halfWidth + ratio * bounds.halfWidth * 2, 0, 0];
});
}
function rectanglePerimeterPosition(ratio, halfWidth, halfHeight) {
const width = halfWidth * 2;
const height = halfHeight * 2;
const perimeter = 2 * (width + height);
let distance = ratio * perimeter;
if (distance <= width) return [-halfWidth + distance, -halfHeight, 0];
distance -= width;
if (distance <= height) return [halfWidth, -halfHeight + distance, 0];
distance -= height;
if (distance <= width) return [halfWidth - distance, halfHeight, 0];
distance -= width;
return [-halfWidth, halfHeight - distance, 0];
}
function ringPositions(options, bounds, shape) {
const count = boundedInteger(options.count ?? 16, "마이크 수", 1, MAX_MICROPHONES);
if (count === 1) return [[0, 0, 0]];
return Array.from({ length: count }, (_, index) => {
const ratio = index / count;
if (shape === "rectangle") {
return rectanglePerimeterPosition(ratio, bounds.halfWidth, bounds.halfHeight);
}
const angle = ratio * Math.PI * 2;
return [bounds.halfWidth * Math.cos(angle), bounds.halfHeight * Math.sin(angle), 0];
});
}
function spiralPositions(options, bounds) {
const count = boundedInteger(options.count ?? 32, "마이크 수", 1, MAX_MICROPHONES);
return Array.from({ length: count }, (_, index) => {
const radius = Math.sqrt((index + 0.5) / count);
const angle = index * GOLDEN_ANGLE;
return [
bounds.halfWidth * radius * Math.cos(angle),
bounds.halfHeight * radius * Math.sin(angle),
0,
];
});
}
function createSeededRandom(seedValue) {
let seed = Number(seedValue);
if (!Number.isFinite(seed)) seed = 1;
let state = Math.trunc(seed) >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
function randomCandidate(random, bounds, shape) {
if (shape === "ellipse") {
const radius = Math.sqrt(random());
const angle = random() * Math.PI * 2;
return [
bounds.halfWidth * radius * Math.cos(angle),
bounds.halfHeight * radius * Math.sin(angle),
0,
];
}
return [
(random() * 2 - 1) * bounds.halfWidth,
(random() * 2 - 1) * bounds.halfHeight,
0,
];
}
function squaredDistance(left, right) {
const dx = left[0] - right[0];
const dy = left[1] - right[1];
const dz = left[2] - right[2];
return dx * dx + dy * dy + dz * dz;
}
function randomPositions(options, bounds, shape) {
const count = boundedInteger(options.count ?? 32, "마이크 수", 1, MAX_MICROPHONES);
const minimumSpacing = finiteNumber(options.minimumSpacingM ?? 0, "최소 간격");
if (minimumSpacing < 0) throw new RangeError("최소 간격은 0 이상이어야 합니다.");
const minimumSpacingSquared = minimumSpacing * minimumSpacing;
const random = createSeededRandom(options.seed ?? 1);
const positions = [];
const keys = new Set();
const maximumAttempts = Math.max(2000, count * 500);
for (let attempt = 0; attempt < maximumAttempts && positions.length < count; attempt += 1) {
const candidate = normalizedPosition(randomCandidate(random, bounds, shape));
const key = pointKey(candidate);
if (keys.has(key)) continue;
if (positions.some((position) => squaredDistance(position, candidate) < minimumSpacingSquared)) {
continue;
}
keys.add(key);
positions.push(candidate);
}
if (positions.length !== count) {
throw new RangeError(
"지정한 aperture와 최소 간격으로 " + count +
"개를 배치할 수 없습니다. 마이크 수나 최소 간격을 줄이세요.",
);
}
return positions;
}
export function sanitizeArrayId(value, fallback = "custom-array") {
const sanitized = String(value ?? "")
.toLowerCase()
.normalize("NFKD")
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "");
return sanitized || fallback;
}
export function safeArrayFileStem(arrayDefinition) {
return sanitizeArrayId(arrayDefinition?.id || arrayDefinition?.name, "microphone-array");
}
export function generatePresetMicrophones(options = {}) {
const shape = normalizedShape(options.shape);
const bounds = presetBounds(options.aperture, options.edgeInsetM);
let positions;
if (options.type === "linear") positions = linearPositions(options, bounds, shape);
else if (options.type === "ring") positions = ringPositions(options, bounds, shape);
else if (options.type === "spiral") positions = spiralPositions(options, bounds);
else if (options.type === "random") positions = randomPositions(options, bounds, shape);
else positions = gridPositions(options, bounds, shape);
return positions.map(microphoneFromPosition);
}
export function createArrayDefinition(options = {}) {
const aperture = normalizedAperture({
width: options.width ?? 0.45,
height: options.height ?? 0.45,
});
const arrayDefinition = {
schemaVersion: "1.0.0",
kind: "micarrsim.array",
id: sanitizeArrayId(options.id ?? "custom-array"),
name: String(options.name ?? "Custom 4x3 array"),
description: String(options.description ?? "Array Builder에서 생성한 사용자 마이크 배열"),
units: "m",
coordinateSystem: { ...COORDINATE_SYSTEM },
aperture,
microphones: [],
};
arrayDefinition.microphones = generatePresetMicrophones({
type: options.presetType ?? "grid",
aperture,
shape: options.shape ?? "rectangle",
columns: options.columns ?? 4,
rows: options.rows ?? 3,
count: options.count,
seed: options.seed,
minimumSpacingM: options.minimumSpacingM,
edgeInsetM: options.edgeInsetM,
axis: options.axis,
});
return arrayDefinition;
}
export function prepareArrayForEditing(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError("Array 문서 객체가 필요합니다.");
}
return structuredClone(value);
}
export function inferApertureShape(arrayDefinition) {
const description = String(arrayDefinition?.description ?? "");
if (/circle|circular|ellipse|elliptic|ring|spiral/i.test(description)) return "ellipse";
const aperture = arrayDefinition?.aperture;
const microphones = arrayDefinition?.microphones ?? [];
if (!aperture || microphones.length < 3) return "rectangle";
let maximumNormalizedRadius = 0;
for (const microphone of microphones) {
const position = microphone?.position;
if (!Array.isArray(position)) return "rectangle";
const radius = Math.hypot(
position[0] / (aperture.width / 2),
position[1] / (aperture.height / 2),
);
if (radius > 1.000000001) return "rectangle";
maximumNormalizedRadius = Math.max(maximumNormalizedRadius, radius);
}
return maximumNormalizedRadius >= 0.72 ? "ellipse" : "rectangle";
}
export function nextMicrophoneId(arrayDefinition) {
const used = new Set((arrayDefinition?.microphones ?? []).map((microphone) => microphone.id));
let maximumSuffix = 0;
for (const id of used) {
const match = /^mic-(\d+)$/.exec(id);
if (match) maximumSuffix = Math.max(maximumSuffix, Number(match[1]));
}
let candidate = maximumSuffix + 1;
while (used.has(microphoneId(candidate))) candidate += 1;
return microphoneId(candidate);
}
export function addMicrophone(arrayDefinition, position, properties = {}) {
if (arrayDefinition.microphones.length >= MAX_MICROPHONES) {
throw new RangeError("마이크는 최대 " + MAX_MICROPHONES + "개까지 추가할 수 있습니다.");
}
const microphone = {
id: properties.id || nextMicrophoneId(arrayDefinition),
position: normalizedPosition(position),
};
for (const key of ["sensitivityDb", "phaseOffsetDeg", "frequencyResponse"]) {
if (properties[key] !== undefined) microphone[key] = structuredClone(properties[key]);
}
arrayDefinition.microphones.push(microphone);
return microphone;
}
export function updateMicrophone(arrayDefinition, microphoneIdValue, changes) {
const microphone = arrayDefinition.microphones.find((item) => item.id === microphoneIdValue);
if (!microphone) throw new Error("마이크를 찾을 수 없습니다: " + microphoneIdValue);
if (changes.id !== undefined && changes.id !== microphone.id) {
const nextId = String(changes.id).trim();
if (!nextId) throw new Error("마이크 ID는 비어 있을 수 없습니다.");
if (arrayDefinition.microphones.some((item) => item !== microphone && item.id === nextId)) {
throw new Error("이미 사용 중인 마이크 ID입니다: " + nextId);
}
microphone.id = nextId;
}
if (changes.position !== undefined) microphone.position = normalizedPosition(changes.position);
for (const key of ["sensitivityDb", "phaseOffsetDeg"]) {
if (!Object.hasOwn(changes, key)) continue;
if (changes[key] === null || changes[key] === "") delete microphone[key];
else microphone[key] = finiteNumber(changes[key], key);
}
return microphone;
}
export function removeMicrophone(arrayDefinition, microphoneIdValue) {
if (arrayDefinition.microphones.length <= 1) return false;
const index = arrayDefinition.microphones.findIndex((item) => item.id === microphoneIdValue);
if (index < 0) return false;
arrayDefinition.microphones.splice(index, 1);
return true;
}
export function moveMicrophone(arrayDefinition, microphoneIdValue, delta) {
const index = arrayDefinition.microphones.findIndex((item) => item.id === microphoneIdValue);
if (index < 0) return false;
const target = Math.max(0, Math.min(arrayDefinition.microphones.length - 1, index + delta));
if (target === index) return false;
const [microphone] = arrayDefinition.microphones.splice(index, 1);
arrayDefinition.microphones.splice(target, 0, microphone);
return true;
}
export function replaceMicrophonesWithPreset(arrayDefinition, options) {
const microphones = generatePresetMicrophones({
...options,
aperture: options?.aperture ?? arrayDefinition.aperture,
});
arrayDefinition.microphones = microphones;
return microphones;
}
export function positionInsideAperture(position, aperture, shape = "rectangle", tolerance = 1e-8) {
const dimensions = normalizedAperture(aperture);
const normalized = normalizedPosition(position);
const halfWidth = dimensions.width / 2;
const halfHeight = dimensions.height / 2;
if (normalizedShape(shape) === "ellipse") {
const radiusSquared =
(normalized[0] * normalized[0]) / (halfWidth * halfWidth) +
(normalized[1] * normalized[1]) / (halfHeight * halfHeight);
return radiusSquared <= 1 + tolerance;
}
return Math.abs(normalized[0]) <= halfWidth + tolerance &&
Math.abs(normalized[1]) <= halfHeight + tolerance;
}
export function clampPositionToAperture(position, aperture, shape = "rectangle") {
const dimensions = normalizedAperture(aperture);
const normalized = normalizedPosition(position);
const halfWidth = dimensions.width / 2;
const halfHeight = dimensions.height / 2;
if (normalizedShape(shape) === "ellipse") {
const radius = Math.hypot(normalized[0] / halfWidth, normalized[1] / halfHeight);
if (radius > 1) {
normalized[0] /= radius;
normalized[1] /= radius;
}
} else {
normalized[0] = Math.max(-halfWidth, Math.min(halfWidth, normalized[0]));
normalized[1] = Math.max(-halfHeight, Math.min(halfHeight, normalized[1]));
}
return normalized.map(rounded);
}
export function snapPosition(position, stepM, aperture, shape = "rectangle") {
const step = finiteNumber(stepM, "snap 간격");
const normalized = normalizedPosition(position);
if (step > 0) {
normalized[0] = Math.round(normalized[0] / step) * step;
normalized[1] = Math.round(normalized[1] / step) * step;
normalized[2] = Math.round(normalized[2] / step) * step;
}
return aperture
? clampPositionToAperture(normalized, aperture, shape)
: normalized.map(rounded);
}
export function centerMicrophones(arrayDefinition) {
const microphones = arrayDefinition.microphones;
if (microphones.length === 0) return [0, 0, 0];
const centroid = [0, 1, 2].map((axis) =>
microphones.reduce((sum, microphone) => sum + microphone.position[axis], 0) / microphones.length);
for (const microphone of microphones) {
microphone.position = microphone.position.map((value, axis) => rounded(value - centroid[axis]));
}
return centroid;
}
export function fitApertureToMicrophones(arrayDefinition, options = {}) {
const microphones = arrayDefinition.microphones;
if (microphones.length === 0) return arrayDefinition.aperture;
const padding = Math.max(0, finiteNumber(options.paddingM ?? 0.005, "aperture 여백"));
const minimumSize = Math.max(0.000001, finiteNumber(options.minimumSizeM ?? 0.001, "최소 aperture"));
const maximumX = Math.max(...microphones.map((microphone) => Math.abs(microphone.position[0])));
const maximumY = Math.max(...microphones.map((microphone) => Math.abs(microphone.position[1])));
let width = Math.max(minimumSize, maximumX * 2);
let height = Math.max(minimumSize, maximumY * 2);
if (normalizedShape(options.shape) === "ellipse") {
let maximumRadius = 0;
for (const microphone of microphones) {
maximumRadius = Math.max(
maximumRadius,
Math.hypot(
(2 * microphone.position[0]) / width,
(2 * microphone.position[1]) / height,
),
);
}
if (maximumRadius > 1) {
width *= maximumRadius;
height *= maximumRadius;
}
}
arrayDefinition.aperture.width = rounded(Math.min(20, width + padding * 2));
arrayDefinition.aperture.height = rounded(Math.min(20, height + padding * 2));
return arrayDefinition.aperture;
}
export function arrayGeometryIssues(arrayDefinition, options = {}) {
const issues = [];
const shape = normalizedShape(options.shape);
const positions = new Map();
for (let index = 0; index < arrayDefinition.microphones.length; index += 1) {
const microphone = arrayDefinition.microphones[index];
const key = pointKey(microphone.position);
if (positions.has(key)) {
issues.push({
severity: "error",
path: "$.microphones[" + index + "].position",
message: microphone.id + "와 " + positions.get(key) + "의 위치가 중복됩니다.",
});
} else {
positions.set(key, microphone.id);
}
if (!positionInsideAperture(microphone.position, arrayDefinition.aperture, shape)) {
issues.push({
severity: "warning",
path: "$.microphones[" + index + "].position",
message: microphone.id + "가 " + shape + " 생성 경계 밖에 있습니다.",
});
}
}
return issues;
}
export function arrayStatistics(arrayDefinition) {
const microphones = arrayDefinition.microphones;
if (microphones.length === 0) {
return { count: 0, centroid: [0, 0, 0], minimumSpacing: null, calibrationCount: 0 };
}
const centroid = [0, 1, 2].map((axis) =>
microphones.reduce((sum, microphone) => sum + microphone.position[axis], 0) / microphones.length);
let minimumSpacingSquared = Infinity;
for (let left = 0; left < microphones.length; left += 1) {
for (let right = left + 1; right < microphones.length; right += 1) {
minimumSpacingSquared = Math.min(
minimumSpacingSquared,
squaredDistance(microphones[left].position, microphones[right].position),
);
}
}
const calibrationCount = microphones.filter((microphone) =>
microphone.sensitivityDb !== undefined ||
microphone.phaseOffsetDeg !== undefined ||
microphone.frequencyResponse !== undefined).length;
return {
count: microphones.length,
centroid: centroid.map(rounded),
minimumSpacing: Number.isFinite(minimumSpacingSquared) ? Math.sqrt(minimumSpacingSquared) : null,
calibrationCount,
};
}
+1
View File
@@ -19,6 +19,7 @@ try {
await import("/tests/unit/camera.test.js"); await import("/tests/unit/camera.test.js");
await import("/tests/unit/webgpu.test.js"); await import("/tests/unit/webgpu.test.js");
await import("/tests/unit/split-pane.test.js"); await import("/tests/unit/split-pane.test.js");
await import("/tests/unit/array-builder.test.js");
await import("/tests/integration/cpu-baseline.test.js"); await import("/tests/integration/cpu-baseline.test.js");
await import("/tests/integration/webgpu.test.js"); await import("/tests/integration/webgpu.test.js");
await import("/tests/integration/server.test.js"); await import("/tests/integration/server.test.js");
+197
View File
@@ -0,0 +1,197 @@
import {
MAX_MICROPHONES,
addMicrophone,
arrayGeometryIssues,
centerMicrophones,
clampPositionToAperture,
createArrayDefinition,
fitApertureToMicrophones,
generatePresetMicrophones,
inferApertureShape,
moveMicrophone,
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("타원 격자는 요청한 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("불가능한 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");
});