Files
micarrsim/apps/array-builder/model.js
T

581 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => {
const number = finiteNumber(value, "좌표");
return Object.is(number, -0) ? "0" : String(number);
}).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 cellSize = Math.max(minimumSpacing, 10 ** -COORDINATE_DIGITS);
const spatialCells = minimumSpacingSquared > 0 ? new Map() : null;
const cellCoordinates = (position) => [
Math.floor(position[0] / cellSize),
Math.floor(position[1] / cellSize),
];
const cellKey = (x, y) => String(x) + "," + String(y);
const violatesMinimumSpacing = (candidate) => {
if (!spatialCells) return false;
const [cellX, cellY] = cellCoordinates(candidate);
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
const neighbors = spatialCells.get(cellKey(cellX + offsetX, cellY + offsetY));
if (neighbors?.some(
(position) => squaredDistance(position, candidate) < minimumSpacingSquared,
)) return true;
}
}
return false;
};
const indexPosition = (position) => {
if (!spatialCells) return;
const [cellX, cellY] = cellCoordinates(position);
const key = cellKey(cellX, cellY);
const cell = spatialCells.get(key);
if (cell) cell.push(position);
else spatialCells.set(key, [position]);
};
const maximumAttempts = Math.max(2000, count * 150);
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 (violatesMinimumSpacing(candidate)) continue;
keys.add(key);
positions.push(candidate);
indexPosition(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 shapeWords = [
arrayDefinition?.id,
arrayDefinition?.name,
arrayDefinition?.description,
].filter(Boolean).join(" ");
return /(^|[^a-z])(circle|circular|ellipse|elliptic|ring|spiral)([^a-z]|$)/i.test(shapeWords)
? "ellipse"
: "rectangle";
}
export function nextMicrophoneId(arrayDefinition) {
const used = new Set((arrayDefinition?.microphones ?? []).map((microphone) => microphone.id));
let maximumSuffix = 0n;
for (const id of used) {
const match = /^mic-(\d+)$/.exec(id);
if (!match) continue;
const suffix = BigInt(match[1]);
if (suffix > maximumSuffix) maximumSuffix = suffix;
}
let candidate = maximumSuffix + 1n;
for (let attempt = 0; attempt <= used.size; attempt += 1) {
const id = "mic-" + candidate.toString().padStart(3, "0");
if (id.length <= 64 && !used.has(id)) return id;
candidate += 1n;
}
for (let suffix = 1; suffix <= MAX_MICROPHONES + 1; suffix += 1) {
const id = microphoneId(suffix);
if (!used.has(id)) return id;
}
throw new Error("사용 가능한 마이크 ID를 찾을 수 없습니다.");
}
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) => {
const scale = microphones.reduce(
(maximum, microphone) => Math.max(maximum, Math.abs(microphone.position[axis])),
0,
);
if (scale === 0) return 0;
const normalizedSum = microphones.reduce(
(sum, microphone) => sum + microphone.position[axis] / scale,
0,
);
const normalizedMean = Math.max(-1, Math.min(1, normalizedSum / microphones.length));
return normalizedMean * scale;
});
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,
};
}