539 lines
20 KiB
JavaScript
539 lines
20 KiB
JavaScript
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,
|
||
};
|
||
}
|