feat: add microphone array builder model
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ try {
|
||||
await import("/tests/unit/camera.test.js");
|
||||
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/integration/cpu-baseline.test.js");
|
||||
await import("/tests/integration/webgpu.test.js");
|
||||
await import("/tests/integration/server.test.js");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user