import { validateArrayDefinition, validateExperiment, validateResult, validateScene, } from "/packages/contracts/validation.js"; import { describeFrequencyAliasing, observeFrequencyBand, } from "/packages/acoustics-core/sampling.js"; import { DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES, estimateSynthesisWorkingSet, } from "/packages/acoustics-core/propagation.js"; async function loadJson(path) { const response = await fetch(path); assert(response.ok, `${path} load failed`); return response.json(); } function resultFrequencyObservation(scene, experiment, band, equivalentBandIds = []) { const observation = observeFrequencyBand( band, scene.sampleRate, experiment.stft.fftSize, ); return { model: observation.model, sampleRate: observation.sampleRate, nyquistHz: observation.nyquistHz, fftSize: observation.fftSize, binWidthHz: observation.binWidthHz, requestedMinHz: observation.requestedMinHz, requestedMaxHz: observation.requestedMaxHz, aliased: observation.aliased, crossesFirstNyquist: observation.crossesFirstNyquist, localizationInputModel: observation.localizationInputModel, selectionModel: observation.selectionModel, localizationModel: experiment.beamforming?.algorithm === "gcc-phat" ? "band-limited-gcc-phat-geometric-lag" : experiment.beamforming?.algorithm === "music" ? "near-field-music-at-positive-adc-bin-frequency" : "spherical-steering-at-positive-adc-bin-frequency", originalCarrierUsed: observation.originalCarrierUsed, aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable", foldBoundaryCount: observation.foldBoundaryCount, continuousManyToOne: observation.continuousManyToOne, gridManyToOne: observation.gridManyToOne, foldSegments: observation.foldSegments.map((segment) => ({ ...segment })), adcBins: [...observation.bins], adcBinCenterRanges: observation.adcBinCenterRanges.map((range) => ({ ...range })), adcBinSetKey: observation.adcBinSetKey, equivalentBandIds, }; } function resultSampling(scene) { const toneObservations = []; for (const [scope, entries] of [ ["source", scene.sources ?? []], ["ambient", scene.ambient ?? []], ]) { for (const entry of entries) { if (entry.signal?.type !== "tone") continue; const observation = describeFrequencyAliasing( entry.signal.frequency, scene.sampleRate, ); toneObservations.push({ scope, id: entry.id, analogFrequencyHz: observation.analogFrequencyHz, adcFrequencyHz: observation.adcFrequencyHz, signedAdcFrequencyHz: observation.signedAdcFrequencyHz, aliased: observation.aliased, spectralInversion: observation.spectralInversion, dcEdge: observation.dcEdge, nyquistEdge: observation.nyquistEdge, phaseDegenerate: observation.phaseDegenerate, }); } } return { model: "ideal-real-sampler-no-antialias-filter", sampleRate: scene.sampleRate, nyquistHz: scene.sampleRate / 2, toneObservations, }; } const RECTANGULAR_ARRAY_EXAMPLES = [ { id: "rectangular-2x2-450mm", microphoneCount: 4, columns: 2, rows: 2, width: 0.45, height: 0.45 }, { id: "rectangular-4x3-450mm", microphoneCount: 12, columns: 4, rows: 3, width: 0.45, height: 0.45 }, { id: "rectangular-8x6-450mm", microphoneCount: 48, columns: 8, rows: 6, width: 0.45, height: 0.45 }, { id: "rectangular-16x12", microphoneCount: 192, columns: 16, rows: 12, width: 0.45, height: 0.45 }, { id: "rectangular-24x16-450mm", microphoneCount: 384, columns: 24, rows: 16, width: 0.45, height: 0.45 }, { id: "rectangular-16x12-compact", microphoneCount: 192, columns: 16, rows: 12, width: 0.12, height: 0.12 }, { id: "rectangular-16x12-large", microphoneCount: 192, columns: 16, rows: 12, width: 1.2, height: 1.2 }, ]; test("moving-tone sample Scene and Experiment satisfy their linked contracts", async () => { const [arrayDefinition, scene, experiment] = await Promise.all([ loadJson("/assets/array-geometries/rectangular-16x12.array.json"), loadJson("/assets/sample-scenes/moving-tone.scene.json"), loadJson("/assets/sample-scenes/moving-tone.experiment.json"), ]); const sceneIssues = await validateScene(scene); const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene }); assertEqual(sceneIssues.length, 0, `moving Scene issues: ${JSON.stringify(sceneIssues)}`); assertEqual(experimentIssues.length, 0, `moving Experiment issues: ${JSON.stringify(experimentIssues)}`); assertEqual(experiment.sceneId, scene.id, "moving example linkage"); assert( scene.sources.some((source) => Array.isArray(source.trajectory) && source.trajectory.length >= 2), "moving example contains a usable trajectory", ); }); test("192채널 기준 배열이 계약을 통과한다", async () => { const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json"); assertEqual(arrayDefinition.microphones.length, 192, "microphone count"); assertEqual(arrayDefinition.name, "16x12 rectangular baseline", "fixture encoding"); assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "array issues"); }); test("rectangular array examples cover low-to-high channel counts and compact-to-wide apertures", async () => { const arrays = await Promise.all(RECTANGULAR_ARRAY_EXAMPLES.map(({ id }) => loadJson(`/assets/array-geometries/${id}.array.json`))); for (let index = 0; index < arrays.length; index += 1) { const arrayDefinition = arrays[index]; const expected = RECTANGULAR_ARRAY_EXAMPLES[index]; assertEqual(arrayDefinition.id, expected.id, `${expected.id} fixture id`); assertEqual(arrayDefinition.microphones.length, expected.microphoneCount, `${expected.id} channel count`); assertEqual(arrayDefinition.aperture.width, expected.width, `${expected.id} aperture width`); assertEqual(arrayDefinition.aperture.height, expected.height, `${expected.id} aperture height`); assertEqual( (await validateArrayDefinition(arrayDefinition)).length, 0, `${expected.id} contract issues`, ); assertEqual( new Set(arrayDefinition.microphones.map(({ id }) => id)).size, expected.microphoneCount, `${expected.id} microphone ids are unique`, ); assertEqual( new Set(arrayDefinition.microphones.map(({ position }) => position.join(","))).size, expected.microphoneCount, `${expected.id} positions are unique`, ); const xCoordinates = arrayDefinition.microphones.map(({ position }) => position[0]); const yCoordinates = arrayDefinition.microphones.map(({ position }) => position[1]); assert( arrayDefinition.microphones.every(({ position }) => position[2] === 0), `${expected.id} microphones stay on the z=0 plane`, ); assertEqual(new Set(xCoordinates).size, expected.columns, `${expected.id} column count`); assertEqual(new Set(yCoordinates).size, expected.rows, `${expected.id} row count`); const uniqueX = [...new Set(xCoordinates)].sort((left, right) => left - right); const uniqueY = [...new Set(yCoordinates)].sort((left, right) => left - right); const expectedXPitch = expected.width / (expected.columns - 1); const expectedYPitch = expected.height / (expected.rows - 1); assert( uniqueX.slice(1).every((value, position) => Math.abs(value - uniqueX[position] - expectedXPitch) < 1.1e-6), `${expected.id} uniform x pitch`, ); assert( uniqueY.slice(1).every((value, position) => Math.abs(value - uniqueY[position] - expectedYPitch) < 1.1e-6), `${expected.id} uniform y pitch`, ); assert(Math.abs(Math.min(...xCoordinates) + expected.width / 2) < 1e-6, `${expected.id} x minimum`); assert(Math.abs(Math.max(...xCoordinates) - expected.width / 2) < 1e-6, `${expected.id} x maximum`); assert(Math.abs(Math.min(...yCoordinates) + expected.height / 2) < 1e-6, `${expected.id} y minimum`); assert(Math.abs(Math.max(...yCoordinates) - expected.height / 2) < 1e-6, `${expected.id} y maximum`); } const channelSeries = arrays.slice(0, 5); assert( channelSeries.every(({ aperture }) => aperture.width === 0.45 && aperture.height === 0.45), "channel-count series keeps the 0.45 m aperture fixed", ); assertEqual( channelSeries.map(({ microphones }) => microphones.length).join(","), "4,12,48,192,384", "channel-count series", ); const apertureSeries = [ arrays.find(({ id }) => id === "rectangular-16x12-compact"), arrays.find(({ id }) => id === "rectangular-16x12"), arrays.find(({ id }) => id === "rectangular-16x12-large"), ]; assert( apertureSeries.every(({ microphones }) => microphones.length === 192), "aperture series keeps 192 channels fixed", ); assertEqual( apertureSeries.map(({ aperture }) => aperture.width).join(","), "0.12,0.45,1.2", "compact-to-large aperture series", ); assertEqual( Math.max(...arrays.map(({ microphones }) => microphones.length)), 384, "high-count example", ); }); test("192채널 Fermat spiral 비교 배열이 계약과 원형 aperture를 만족한다", async () => { const arrayDefinition = await loadJson("/assets/array-geometries/fermat-spiral-192.array.json"); assertEqual(arrayDefinition.microphones.length, 192, "spiral microphone count"); assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "spiral array issues"); const maximumRadius = Math.max( ...arrayDefinition.microphones.map(({ position }) => Math.hypot(position[0], position[1])), ); assert(maximumRadius <= 0.225001, `spiral aperture radius: ${maximumRadius}`); assertEqual( new Set(arrayDefinition.microphones.map(({ position }) => position.join(","))).size, 192, "spiral positions are unique", ); }); test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () => { const [arrayDefinition, scene, experiment] = await Promise.all([ loadJson("/assets/array-geometries/rectangular-16x12.array.json"), loadJson("/assets/sample-scenes/single-tone.scene.json"), loadJson("/assets/sample-scenes/single-tone.experiment.json"), ]); assertEqual((await validateScene(scene)).length, 0, "scene issues"); assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues"); }); test("네 위치 추정 알고리즘과 backend 제약을 계약이 검증한다", async () => { const [arrayDefinition, scene, baseline] = await Promise.all([ loadJson("/assets/array-geometries/rectangular-16x12.array.json"), loadJson("/assets/sample-scenes/single-tone.scene.json"), loadJson("/assets/sample-scenes/single-tone.experiment.json"), ]); for (const algorithm of ["das", "das-phat", "gcc-phat", "music"]) { const experiment = structuredClone(baseline); experiment.beamforming.algorithm = algorithm; experiment.beamforming.backend = algorithm === "gcc-phat" || algorithm === "music" ? "cpu" : "auto"; if (algorithm === "music") { experiment.frequencyBands = [ { id: "single-bin", minHz: 1000, maxHz: 1050, color: "#65f2bf" }, ]; experiment.beamforming.normalization = "none"; experiment.beamforming.musicSourceCountMethod = "fixed"; experiment.beamforming.musicSourceCount = 1; } assertEqual( (await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, `${algorithm} experiment issues`, ); } const autoWithStaleK = structuredClone(baseline); autoWithStaleK.frequencyBands = [ { id: "single-bin", minHz: 1000, maxHz: 1050, color: "#65f2bf" }, ]; autoWithStaleK.stft.hopSize = autoWithStaleK.stft.fftSize; Object.assign(autoWithStaleK.beamforming, { algorithm: "music", backend: "cpu", normalization: "none", musicSourceCountMethod: "mdl", musicSourceCount: 8, }); const smallArray = { ...arrayDefinition, microphones: arrayDefinition.microphones.slice(0, 4), }; assertEqual( (await validateExperiment(autoWithStaleK, { arrayDefinition: smallArray, scene, })).length, 0, "MDL ignores disabled stale fixed K after an array-size change", ); const incompatible = structuredClone(baseline); incompatible.beamforming.algorithm = "gcc-phat"; incompatible.beamforming.backend = "webgpu"; let issues = await validateExperiment(incompatible, { arrayDefinition, scene }); assert( issues.some(({ path }) => path === "$.beamforming.backend"), "GCC-PHAT rejects the WebGPU backend", ); incompatible.beamforming.backend = "cpu"; issues = await validateExperiment(incompatible, { arrayDefinition: { ...arrayDefinition, microphones: arrayDefinition.microphones.slice(0, 1) }, scene, }); assert( issues.some(({ path }) => path === "$.beamforming.algorithm"), "GCC-PHAT requires two microphones", ); incompatible.beamforming.algorithm = "invented-localizer"; issues = await validateExperiment(incompatible, { arrayDefinition, scene }); assert( issues.some(({ path }) => path === "$.beamforming.algorithm"), "unsupported algorithms are rejected", ); }); test("shoebox 음향 환경의 room과 occluder 경계를 검증한다", async () => { const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json"); scene.acoustics = { model: "shoebox", room: { min: [-2, -2, -1], max: [2, 2, 3] }, reflections: { order: 1, wallGainDb: -8, scattering: 0.2 }, diffuseReverb: { enabled: true, rt60Seconds: 0.35, gainDb: -20, tapCount: 12 }, occluders: [{ id: "screen", min: [-0.2, -0.2, 0.8], max: [0.2, 0.2, 1], transmissionDb: -18, diffractionGainDb: -12, }], }; assertEqual((await validateScene(scene)).length, 0, "valid shoebox scene"); scene.acoustics.room.max[0] = scene.acoustics.room.min[0]; let issues = await validateScene(scene); assert(issues.some(({ path }) => path === "$.acoustics.room"), "unordered room bounds"); scene.acoustics.room.max[0] = 2; scene.acoustics.occluders[0].max[2] = 4; issues = await validateScene(scene); assert( issues.some(({ path, message }) => path.includes("occluders[0]") && message.includes("room")), "occluder outside room", ); scene.acoustics = { model: "free-field", room: { min: [-2, -2, -1], max: [2, 2, 3] }, }; issues = await validateScene(scene); assert(issues.some(({ path }) => path === "$.acoustics"), "unused free-field settings"); }); test("Experiment 피크 검출 임계값 비율은 재현 가능하고 유효한 범위여야 한다", async () => { const [arrayDefinition, scene, experiment] = await Promise.all([ loadJson("/assets/array-geometries/rectangular-16x12.array.json"), loadJson("/assets/sample-scenes/single-tone.scene.json"), loadJson("/assets/sample-scenes/single-tone.experiment.json"), ]); assertEqual(experiment.evaluation.detectionThresholdRatio, 0.25, "sample threshold ratio"); assertEqual( (await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "0.25 threshold issues", ); experiment.evaluation.detectionThresholdRatio = 0; let issues = await validateExperiment(experiment, { arrayDefinition, scene }); assert( issues.some(({ path, message }) => path === "$.evaluation.detectionThresholdRatio" && message.includes("0보다 크고 1 이하")), "zero threshold is rejected with actionable guidance", ); experiment.evaluation.detectionThresholdRatio = 1.01; issues = await validateExperiment(experiment, { arrayDefinition, scene }); assert( issues.some(({ path }) => path === "$.evaluation.detectionThresholdRatio"), "threshold above one is rejected", ); }); test("이동 음원 trajectory의 keyframe 순서와 장면 시간 범위를 검증한다", async () => { const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json"); scene.sources[0].trajectory = [ { time: 0, position: [-0.2, 0, 2] }, { time: 0.125, position: [0, 0.1, 2] }, { time: 0.25, position: [0.2, 0, 2] }, ]; assertEqual((await validateScene(scene)).length, 0, "valid trajectory issues"); scene.sources[0].trajectory[2].time = 0.125; let issues = await validateScene(scene); assert(issues.some(({ path }) => path === "$.sources[0].trajectory[2].time"), "strictly ordered times"); scene.sources[0].trajectory[2].time = 0.3; issues = await validateScene(scene); assert(issues.some(({ path }) => path === "$.sources[0].trajectory[2].time"), "scene duration bound"); scene.sources[0].trajectory = [{ time: 0, position: [0, 0, 2] }]; issues = await validateScene(scene); assert(issues.some(({ path }) => path === "$.sources[0].trajectory"), "two-keyframe minimum"); }); test("중복 마이크 id를 거부한다", async () => { const arrayDefinition = { schemaVersion: "1.0.0", kind: "micarrsim.array", id: "duplicate-test", name: "duplicate test", units: "m", coordinateSystem: { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" }, aperture: { width: 1, height: 1 }, microphones: [ { id: "mic-1", position: [0, 0, 0] }, { id: "mic-1", position: [0.1, 0, 0] }, ], }; assert((await validateArrayDefinition(arrayDefinition)).some(({ message }) => message.includes("중복"))); }); test("마이크 주파수 응답점은 안전한 범위에서 주파수 오름차순이어야 한다", async () => { const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json"); arrayDefinition.microphones[0].frequencyResponse = [ { frequencyHz: 20, gainDb: -3 }, { frequencyHz: 1000, gainDb: 1.5, phaseDeg: 12 }, { frequencyHz: 20_000, gainDb: -2 }, ]; assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "valid response curve"); arrayDefinition.microphones[0].frequencyResponse[2].frequencyHz = 1000; let issues = await validateArrayDefinition(arrayDefinition); assert( issues.some(({ path, message }) => path === "$.microphones[0].frequencyResponse[2].frequencyHz" && message.includes("엄격히 증가")), "duplicate frequency is rejected with sorting guidance", ); arrayDefinition.microphones[0].frequencyResponse[2].frequencyHz = 1_000_001; issues = await validateArrayDefinition(arrayDefinition); assert( issues.some(({ path }) => path === "$.microphones[0].frequencyResponse[2].frequencyHz"), "globally bounded frequency is enforced without a Scene Nyquist dependency", ); }); test("Nyquist를 넘는 tone을 ideal ADC alias 실험으로 허용한다", async () => { const scene = { schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m", sampleRate: 8000, duration: 1, speedOfSound: 343, seed: 1, mix: { normalization: "none", targetPeak: 0.95 }, sources: [{ id: "source", name: "source", position: [0, 0, 1], gainDb: 0, startTime: 0, duration: 1, signal: { type: "tone", frequency: 5000 }, }], }; assertEqual((await validateScene(scene)).length, 0, "above-Nyquist tone is structurally valid"); scene.sources[0].signal.frequency = 1_000_001; assert( (await validateScene(scene)).some(({ path }) => path === "$.sources[0].signal.frequency"), "tone above the explicit numerical model range is rejected, not silently rounded", ); }); test("Experiment band는 Nyquist 위에서도 허용하되 FFT bin center가 하나는 있어야 한다", async () => { const [scene, experiment] = await Promise.all([ loadJson("/assets/sample-scenes/single-tone.scene.json"), loadJson("/assets/sample-scenes/single-tone.experiment.json"), ]); experiment.frequencyBands[0].minHz = 24_900; experiment.frequencyBands[0].maxHz = 25_100; assertEqual( (await validateExperiment(experiment, { scene })).length, 0, "above-Nyquist band with folded bin centers is valid", ); experiment.frequencyBands[0].minHz = 1_000.1; experiment.frequencyBands[0].maxHz = 1_000.2; let issues = await validateExperiment(experiment, { scene }); assert( issues.some(({ path, message }) => path === "$.frequencyBands[0]" && message.includes("bin center")), "a sub-bin band fails validation before runtime", ); experiment.frequencyBands[0].minHz = 999_900; experiment.frequencyBands[0].maxHz = 1_000_001; issues = await validateExperiment(experiment, { scene }); assert( issues.some(({ path }) => path === "$.frequencyBands[0].maxHz"), "band above the explicit numerical model range is rejected", ); experiment.frequencyBands = {}; issues = await validateExperiment(experiment, { scene }); assert( issues.some(({ path }) => path === "$.frequencyBands"), "malformed frequencyBands returns validation issues instead of throwing", ); }); test("analytic tone-only Scene은 존재하지 않는 delay FFT 메모리로 거부하지 않는다", async () => { const [arrayDefinition, scene, experiment] = await Promise.all([ loadJson("/assets/array-geometries/rectangular-16x12.array.json"), loadJson("/assets/sample-scenes/single-tone.scene.json"), loadJson("/assets/sample-scenes/single-tone.experiment.json"), ]); arrayDefinition.microphones = arrayDefinition.microphones.slice(0, 1); scene.sampleRate = 192_000; scene.duration = 30; scene.sources = Array.from({ length: 32 }, (_, index) => ({ ...structuredClone(scene.sources[0]), id: `tone-${index + 1}`, name: `Tone ${index + 1}`, duration: 30, })); const issues = await validateExperiment(experiment, { arrayDefinition, scene }); assert( !issues.some(({ message }) => message.includes("512 MiB")), `analytic tones must not allocate sampled source/FFT buffers: ${JSON.stringify(issues)}`, ); }); test("Experiment preflight는 runtime과 같은 source-streaming shoebox delay working set을 쓴다", async () => { const [arrayDefinition, scene, experiment] = await Promise.all([ loadJson("/assets/array-geometries/rectangular-16x12.array.json"), loadJson("/assets/sample-scenes/single-tone.scene.json"), loadJson("/assets/sample-scenes/single-tone.experiment.json"), ]); arrayDefinition.microphones = arrayDefinition.microphones.slice(0, 16); scene.sampleRate = 192_000; scene.duration = 4_000_000 / scene.sampleRate; scene.sources = [{ ...structuredClone(scene.sources[0]), id: "long-sampled-source", name: "Long sampled source", position: [0, 0, 1], startTime: 0, duration: scene.duration, signal: { type: "noise", color: "white", seedOffset: 1 }, }]; scene.ambient = []; scene.acoustics = { model: "shoebox", room: { min: [-10, -10, -10], max: [10, 10, 10] }, reflections: { order: 0, wallGainDb: -12, scattering: 0 }, diffuseReverb: { enabled: true, rt60Seconds: 4, gainDb: -18, tapCount: 1, }, occluders: [], }; experiment.beamforming.minimumDistance = 0.01; const workingSet = estimateSynthesisWorkingSet({ scene, arrayDefinition, minimumDistance: experiment.beamforming.minimumDistance, }); assertEqual(workingSet.sampleCount, 4_000_000, "fixture sample count"); assertEqual( workingSet.maximumTransient.fftSize, 8_388_608, "diffuse delay crosses the FFT power-of-two boundary", ); assert( workingSet.estimatedBufferBytes > DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES, "shared runtime estimate exceeds 512 MiB", ); const issues = await validateExperiment(experiment, { arrayDefinition, scene, }); assert( issues.some(({ message }) => message.includes("source-streaming") && message.includes("512 MiB")), `validator must reject the same shared estimate: ${JSON.stringify(issues)}`, ); scene.sources[0].signal = { type: "tone", frequency: 1_000, phaseDeg: 0 }; const toneWorkingSet = estimateSynthesisWorkingSet({ scene, arrayDefinition, minimumDistance: experiment.beamforming.minimumDistance, }); assertEqual( toneWorkingSet.estimatedBufferBytes, toneWorkingSet.channelBytes, "analytic tone has no sampled FFT transient", ); const toneIssues = await validateExperiment(experiment, { arrayDefinition, scene, }); assert( !toneIssues.some(({ message }) => message.includes("512 MiB")), "validator does not charge analytic tone for a nonexistent delay FFT", ); }); test("NaN 좌표를 거부한다", async () => { const arrayDefinition = { schemaVersion: "1.0.0", kind: "micarrsim.array", id: "nan-test", name: "NaN test", units: "m", coordinateSystem: { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" }, aperture: { width: 1, height: 1 }, microphones: [{ id: "mic-1", position: [Number.NaN, 0, 0] }], }; assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("[0]"))); }); test("JSON Schema의 누락 필드와 범위·알 수 없는 필드를 거부한다", async () => { const [arrayDefinition, scene, experiment] = await Promise.all([ loadJson("/assets/array-geometries/rectangular-16x12.array.json"), loadJson("/assets/sample-scenes/single-tone.scene.json"), loadJson("/assets/sample-scenes/single-tone.experiment.json"), ]); arrayDefinition.microphones[0].phaseOffsetDeg = 999; scene.unexpected = true; delete experiment.beamforming.normalization; experiment.beamforming.dynamicRangeDb = 4; experiment.beamforming.compensateFrequencyResponse = "false"; experiment.beamforming.maxCompensationDb = 121; assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("phaseOffsetDeg"))); assert((await validateScene(scene)).some(({ path }) => path === "$.unexpected")); const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene }); assert(experimentIssues.some(({ path }) => path.endsWith("normalization"))); assert(experimentIssues.some(({ path }) => path.endsWith("dynamicRangeDb"))); assert(experimentIssues.some(({ path }) => path.endsWith("compensateFrequencyResponse"))); assert(experimentIssues.some(({ path }) => path.endsWith("maxCompensationDb"))); }); test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => { const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json"); scene.assets = [ { id: "audio", kind: "audio", fileName: "../outside.wav", mimeType: "audio/wav" }, { id: "image", kind: "image", fileName: "machine.png", mimeType: "image/png" }, ]; scene.image = { assetId: "audio", widthPixels: 600, heightPixels: 400, plane: { z: 2, xMin: -0.6, xMax: 0.6, yMin: -0.4, yMax: 0.4 }, }; scene.sources[0].signal = { type: "asset", assetId: "image" }; const issues = await validateScene(scene); assert(issues.some(({ path }) => path.endsWith("fileName"))); assertEqual(issues.filter(({ message }) => message.includes("asset을 참조")).length, 2); }); test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", async () => { const [scene, experiment] = await Promise.all([ loadJson("/assets/sample-scenes/single-tone.scene.json"), loadJson("/assets/sample-scenes/single-tone.experiment.json"), ]); const result = { schemaVersion: "1.1.0", kind: "micarrsim.result", experimentId: experiment.id, arrayId: experiment.arrayId, sceneId: experiment.sceneId, createdAt: "2026-08-28T00:00:00.000Z", backend: "cpu", algorithm: "das", inputHash: "a".repeat(64), signalStats: { preNormalizationPeak: 0.5, appliedScale: 1, clippedSampleCount: 0, sampling: resultSampling(scene), }, timingMs: { total: 10, signalGeneration: 3, localization: 7, beamforming: 7, }, bands: [{ id: "tone-1k", color: "#65f2bf", frequencyObservation: resultFrequencyObservation( scene, experiment, experiment.frequencyBands[0], ), rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) }, peaks: [{ sourceId: "source-1", position: [0.08, 0.04, 2], value: 1, localizationErrorM: 0, beamSpread: { calibrated: false, method: "connected-half-power-power-weighted-covariance", halfPowerRatio: 0.5, thresholdValue: 0.5, pixelCount: 1, centroid: [0.08, 0.04, 2], covarianceM2: { xx: 0, xy: 0, yy: 0 }, ellipse: { axisScale: "rms", angleReference: "counterclockwise-from-positive-x", majorRmsRadiusM: 0, minorRmsRadiusM: 0, angleDeg: 0, }, }, }], metrics: { energyInsideTruthRatio: 1, precision: 1, recall: 1 }, }], }; assertEqual((await validateResult(result, { experiment, scene })).length, 0, "valid result issues"); const mismatchedTimingResult = structuredClone(result); mismatchedTimingResult.timingMs.localization = 8; const mismatchedTimingIssues = await validateResult( mismatchedTimingResult, { experiment, scene }, ); assert( mismatchedTimingIssues.some( ({ path }) => path === "$.timingMs.localization", ), "localization timing and its compatibility alias must agree", ); const missingLocalizationTiming = structuredClone(result); delete missingLocalizationTiming.timingMs.localization; const missingLocalizationIssues = await validateResult( missingLocalizationTiming, { experiment, scene }, ); assert( missingLocalizationIssues.some( ({ path }) => path === "$.timingMs.localization", ), "Result 1.1 requires generic localization timing", ); const legacyResult = structuredClone(result); legacyResult.schemaVersion = "1.0.0"; const legacyIssues = await validateResult(legacyResult, { experiment, scene, }); assert( legacyIssues.some(({ path }) => path === "$.schemaVersion"), "sampling-aware Result validation must reject legacy 1.0 implicitly", ); const impossibleBackendResult = structuredClone(result); impossibleBackendResult.algorithm = "gcc-phat"; impossibleBackendResult.backend = "webgpu"; const impossibleBackendIssues = await validateResult( impossibleBackendResult, ); assert( impossibleBackendIssues.some(({ path }) => path === "$.backend"), "standalone Result rejects the unsupported GCC-PHAT WebGPU combination", ); const malformedResult = structuredClone(result); delete malformedResult.bands[0].id; malformedResult.bands[0].processedMaps = {}; const malformedIssues = await validateResult(malformedResult, { experiment, scene, }); assert( malformedIssues.length > 0, "malformed Result fields return validation issues instead of throwing", ); experiment.beamforming.algorithm = "das-phat"; result.algorithm = "das-phat"; result.bands[0].rawMap.scale = "score"; result.bands[0].peaks[0].beamSpread.method = "connected-half-maximum-score-weighted-covariance"; assertEqual( (await validateResult(result, { experiment, scene })).length, 0, "phase-weighted score result issues", ); result.bands[0].rawMap.scale = "power"; let issues = await validateResult(result, { experiment, scene }); assert( issues.some(({ path }) => path.endsWith("rawMap.scale")), "algorithm and raw map scale must agree", ); result.bands[0].rawMap.scale = "score"; result.bands[0].peaks[0].beamSpread.method = "connected-half-power-power-weighted-covariance"; issues = await validateResult(result, { experiment, scene }); assert( issues.some(({ path }) => path.endsWith("beamSpread.method")), "score map and beam spread method must agree", ); result.bands[0].peaks[0].beamSpread.method = "connected-half-maximum-score-weighted-covariance"; result.algorithm = "das"; issues = await validateResult(result, { experiment, scene }); assert(issues.some(({ path }) => path === "$.algorithm"), "result algorithm must match its experiment"); experiment.beamforming.algorithm = "das"; result.bands[0].rawMap.scale = "power"; result.bands[0].peaks[0].beamSpread.method = "connected-half-power-power-weighted-covariance"; result.bands[0].rawMap.values.pop(); result.bands[0].rawMap.scale = "normalized"; result.bands[0].peaks[0].sourceId = "missing-source"; issues = await validateResult(result, { experiment, scene }); assert(issues.some(({ message }) => message.includes("rows × columns"))); assert(issues.some(({ path }) => path.endsWith("rawMap.scale"))); assert(issues.some(({ path }) => path.endsWith("sourceId"))); });