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, }; }