From acf68f49312c70b66447a13117de1dd81b5a92c5 Mon Sep 17 00:00:00 2001 From: MasterBabol Date: Fri, 28 Aug 2026 22:00:57 +0900 Subject: [PATCH] feat: add microphone array builder model --- apps/array-builder/model.js | 538 +++++++++++++++++++++++++++++++ tests/test-runner.js | 1 + tests/unit/array-builder.test.js | 197 +++++++++++ 3 files changed, 736 insertions(+) create mode 100644 apps/array-builder/model.js create mode 100644 tests/unit/array-builder.test.js diff --git a/apps/array-builder/model.js b/apps/array-builder/model.js new file mode 100644 index 0000000..99aa90a --- /dev/null +++ b/apps/array-builder/model.js @@ -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, + }; +} diff --git a/tests/test-runner.js b/tests/test-runner.js index 59a791d..44058fe 100644 --- a/tests/test-runner.js +++ b/tests/test-runner.js @@ -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"); diff --git a/tests/unit/array-builder.test.js b/tests/unit/array-builder.test.js new file mode 100644 index 0000000..6f9a4d7 --- /dev/null +++ b/tests/unit/array-builder.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"); +});