feat: expose acoustic realism controls

This commit is contained in:
Codex
2026-08-28 18:24:10 +09:00
parent d002a2bd70
commit 6e5bd512b9
9 changed files with 314 additions and 7 deletions
+122
View File
@@ -0,0 +1,122 @@
const PROFILE_DEFINITIONS = Object.freeze({
"treated-room": Object.freeze({
reflection: Object.freeze({ wallGainDb: -14, scattering: 0.15 }),
diffuse: Object.freeze({ rt60Seconds: 0.2, gainDb: -26, tapCount: 4 }),
}),
"typical-room": Object.freeze({
reflection: Object.freeze({ wallGainDb: -8, scattering: 0.25 }),
diffuse: Object.freeze({ rt60Seconds: 0.4, gainDb: -20, tapCount: 6 }),
}),
"reflective-room": Object.freeze({
reflection: Object.freeze({ wallGainDb: -4, scattering: 0.4 }),
diffuse: Object.freeze({ rt60Seconds: 0.8, gainDb: -14, tapCount: 10 }),
}),
});
function finitePosition(position) {
return Array.isArray(position) && position.length === 3 && position.every(Number.isFinite);
}
function roundCoordinate(value) {
return Number(value.toFixed(6));
}
function geometryPositions(scene, arrayDefinition, experiment) {
const positions = [];
for (const microphone of arrayDefinition?.microphones ?? []) {
if (finitePosition(microphone.position)) positions.push(microphone.position);
}
for (const source of scene?.sources ?? []) {
if (finitePosition(source.position)) positions.push(source.position);
for (const keyframe of source.trajectory ?? []) {
if (finitePosition(keyframe.position)) positions.push(keyframe.position);
}
}
const scan = experiment?.scan;
if (scan && ["xMin", "xMax", "yMin", "yMax", "z"].every((key) => Number.isFinite(scan[key]))) {
positions.push(
[scan.xMin, scan.yMin, scan.z],
[scan.xMin, scan.yMax, scan.z],
[scan.xMax, scan.yMin, scan.z],
[scan.xMax, scan.yMax, scan.z],
);
}
return positions.length > 0 ? positions : [[0, 0, 0], [1, 1, 1]];
}
export function inferShoeboxRoom(scene, arrayDefinition, experiment) {
const positions = geometryPositions(scene, arrayDefinition, experiment);
const minimum = [0, 1, 2].map((axis) =>
Math.min(...positions.map((position) => position[axis])),
);
const maximum = [0, 1, 2].map((axis) =>
Math.max(...positions.map((position) => position[axis])),
);
const padding = [0, 1, 2].map((axis) =>
Math.max(axis === 2 ? 0.75 : 1, (maximum[axis] - minimum[axis]) * 0.35),
);
return {
min: minimum.map((value, axis) => roundCoordinate(value - padding[axis])),
max: maximum.map((value, axis) => roundCoordinate(value + padding[axis])),
};
}
export function applyAcousticProfile(scene, arrayDefinition, experiment, profileId) {
if (!scene || typeof scene !== "object") throw new TypeError("scene이 필요합니다.");
if (profileId === "free-field") {
scene.acoustics = { model: "free-field" };
return scene.acoustics;
}
const definition = PROFILE_DEFINITIONS[profileId];
if (!definition) throw new RangeError("지원하지 않는 음향 환경 profile: " + profileId);
scene.acoustics = {
model: "shoebox",
room: inferShoeboxRoom(scene, arrayDefinition, experiment),
reflections: {
order: 1,
wallGainDb: definition.reflection.wallGainDb,
scattering: definition.reflection.scattering,
},
diffuseReverb: {
enabled: true,
rt60Seconds: definition.diffuse.rt60Seconds,
gainDb: definition.diffuse.gainDb,
tapCount: definition.diffuse.tapCount,
},
};
return scene.acoustics;
}
export function detectAcousticProfile(scene) {
const acoustics = scene?.acoustics;
if (!acoustics || acoustics.model === "free-field") return "free-field";
if (acoustics.model !== "shoebox" || (acoustics.occluders?.length ?? 0) > 0) return "custom";
for (const [profileId, definition] of Object.entries(PROFILE_DEFINITIONS)) {
if (
acoustics.reflections?.order === 1 &&
acoustics.reflections.wallGainDb === definition.reflection.wallGainDb &&
acoustics.reflections.scattering === definition.reflection.scattering &&
acoustics.diffuseReverb?.enabled === true &&
acoustics.diffuseReverb.rt60Seconds === definition.diffuse.rt60Seconds &&
acoustics.diffuseReverb.gainDb === definition.diffuse.gainDb &&
acoustics.diffuseReverb.tapCount === definition.diffuse.tapCount
) {
return profileId;
}
}
return "custom";
}
export function maximumSparsePathsPerReceiver(scene) {
const acoustics = scene?.acoustics;
if (!acoustics || acoustics.model === "free-field") return 1;
let count = 1;
const reflections = acoustics.reflections;
if (reflections?.order === 1) {
count += 6;
if (reflections.scattering > 0) count += 6;
}
count += acoustics.occluders?.length ?? 0;
if (acoustics.diffuseReverb?.enabled) count += acoustics.diffuseReverb.tapCount;
return count;
}
+4 -1
View File
@@ -81,7 +81,7 @@
</div> </div>
<details class="advanced-settings" id="advanced-settings"> <details class="advanced-settings" id="advanced-settings">
<summary><span>고급 설정</span><small>범위 · STFT · 표시 · 식별자</small></summary> <summary><span>고급 설정</span><small>음향 · 범위 · STFT · 식별자</small></summary>
<div class="advanced-settings__body"> <div class="advanced-settings__body">
<div class="advanced-group"> <div class="advanced-group">
<h3>실험과 환경</h3> <h3>실험과 환경</h3>
@@ -90,7 +90,10 @@
<label class="field field--wide"><span>Experiment ID</span><input id="experiment-id" type="text" maxlength="120" /></label> <label class="field field--wide"><span>Experiment ID</span><input id="experiment-id" type="text" maxlength="120" /></label>
<label class="field"><span>음속 (m/s)</span><input id="speed-of-sound" type="number" min="250" max="400" step="0.1" /></label> <label class="field"><span>음속 (m/s)</span><input id="speed-of-sound" type="number" min="250" max="400" step="0.1" /></label>
<label class="field"><span>Seed</span><input id="scene-seed" type="number" min="0" max="4294967295" step="1" /></label> <label class="field"><span>Seed</span><input id="scene-seed" type="number" min="0" max="4294967295" step="1" /></label>
<label class="field field--wide"><span>음향 환경</span><select id="acoustic-profile"><option value="free-field">자유음장 · 직접음만</option><option value="treated-room">흡음된 실내 · 짧은 잔향</option><option value="typical-room">일반 실내 · 균형</option><option value="reflective-room">반사 강한 실내</option><option value="custom" disabled>Scene 사용자 설정</option></select></label>
<label class="field field--wide"><span>마이크 주파수 응답 처리</span><select id="frequency-response-mode"><option value="compensate">측정 curve 적용 + 위치추정에서 역보정</option><option value="raw">측정 curve 적용 + 보정하지 않음</option></select></label>
</div> </div>
<p class="acoustic-environment-summary" id="acoustic-environment-summary">음향 모델을 확인하고 있습니다.</p>
</div> </div>
<div class="advanced-group"> <div class="advanced-group">
<h3>Scan 범위</h3> <h3>Scan 범위</h3>
+69 -2
View File
@@ -7,6 +7,11 @@ import {
validateScene, validateScene,
} from "/packages/contracts/validation.js"; } from "/packages/contracts/validation.js";
import { createCameraAssetRuntime } from "./asset-runtime.js"; import { createCameraAssetRuntime } from "./asset-runtime.js";
import {
applyAcousticProfile,
detectAcousticProfile,
maximumSparsePathsPerReceiver,
} from "./acoustic-profiles.js";
import { import {
createAnalysisFramePlan, createAnalysisFramePlan,
playbackFrameAtTime, playbackFrameAtTime,
@@ -90,6 +95,9 @@ const elements = {
sceneDistance: byId("scene-distance"), sceneDistance: byId("scene-distance"),
speedOfSound: byId("speed-of-sound"), speedOfSound: byId("speed-of-sound"),
sceneSeed: byId("scene-seed"), sceneSeed: byId("scene-seed"),
acousticProfile: byId("acoustic-profile"),
acousticEnvironmentSummary: byId("acoustic-environment-summary"),
frequencyResponseMode: byId("frequency-response-mode"),
backend: byId("backend"), backend: byId("backend"),
gridPreset: byId("grid-preset"), gridPreset: byId("grid-preset"),
scanColumns: byId("scan-columns"), scanColumns: byId("scan-columns"),
@@ -358,6 +366,9 @@ function syncControls() {
elements.sceneDistance.value = experiment.scan.z; elements.sceneDistance.value = experiment.scan.z;
elements.speedOfSound.value = scene.speedOfSound; elements.speedOfSound.value = scene.speedOfSound;
elements.sceneSeed.value = scene.seed; elements.sceneSeed.value = scene.seed;
elements.acousticProfile.value = detectAcousticProfile(scene);
elements.frequencyResponseMode.value =
experiment.beamforming.compensateFrequencyResponse === false ? "raw" : "compensate";
elements.backend.value = experiment.beamforming.backend; elements.backend.value = experiment.beamforming.backend;
elements.scanColumns.value = experiment.scan.columns; elements.scanColumns.value = experiment.scan.columns;
elements.scanRows.value = experiment.scan.rows; elements.scanRows.value = experiment.scan.rows;
@@ -381,6 +392,30 @@ function syncControls() {
: "custom"; : "custom";
} }
function renderAcousticSummary() {
if (!state.scene || !state.arrayDefinition || !state.experiment) return;
const profile = detectAcousticProfile(state.scene);
const profileLabels = {
"free-field": "자유음장",
"treated-room": "흡음된 실내",
"typical-room": "일반 실내",
"reflective-room": "반사 강한 실내",
custom: "Scene 사용자 환경",
};
const responseCurveCount = state.arrayDefinition.microphones.filter(
(microphone) => microphone.frequencyResponse?.length >= 2,
).length;
const responseMode =
state.experiment.beamforming.compensateFrequencyResponse === false
? "응답 역보정 꺼짐"
: "알려진 응답 역보정";
elements.acousticEnvironmentSummary.textContent =
profileLabels[profile] + " · 수신점당 최대 " +
maximumSparsePathsPerReceiver(state.scene) + "개 sparse path · 응답 curve " +
responseCurveCount + "/" + state.arrayDefinition.microphones.length + " · " + responseMode;
document.documentElement.dataset.acousticProfile = profile;
}
function describeSignal(signal) { function describeSignal(signal) {
if (signal.type === "tone") return `${formatNumber(signal.frequency, 0)} Hz tone`; if (signal.type === "tone") return `${formatNumber(signal.frequency, 0)} Hz tone`;
if (signal.type === "noise") return `${signal.color} noise`; if (signal.type === "noise") return `${signal.color} noise`;
@@ -390,6 +425,7 @@ function describeSignal(signal) {
function renderDocumentSummary() { function renderDocumentSummary() {
const { arrayDefinition, scene, experiment } = state; const { arrayDefinition, scene, experiment } = state;
if (!arrayDefinition || !scene || !experiment) return; if (!arrayDefinition || !scene || !experiment) return;
renderAcousticSummary();
elements.workspaceTitle.textContent = scene.name; elements.workspaceTitle.textContent = scene.name;
elements.sceneMeta.textContent = elements.sceneMeta.textContent =
`${scene.sampleRate.toLocaleString("ko-KR")} Hz · ${formatNumber(scene.duration, 3)} s · ` + `${scene.sampleRate.toLocaleString("ko-KR")} Hz · ${formatNumber(scene.duration, 3)} s · ` +
@@ -502,12 +538,19 @@ function renderWorkload() {
if (!state.arrayDefinition || !state.scene || !state.experiment) return; if (!state.arrayDefinition || !state.scene || !state.experiment) return;
try { try {
const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment); const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment);
const maximumPaths = maximumSparsePathsPerReceiver(state.scene);
const signalPathBudget =
workload.microphoneCount * state.scene.sources.length * maximumPaths;
elements.workloadSummary.textContent = elements.workloadSummary.textContent =
`${workload.pointCount.toLocaleString("ko-KR")} points × ${workload.microphoneCount} ch · ` + `${workload.pointCount.toLocaleString("ko-KR")} points × ${workload.microphoneCount} ch · ` +
`${workload.frameCount} frames · ${workload.selectedBinCount} bins · ` + `${workload.frameCount} frames · ${workload.selectedBinCount} bins · ` +
`약 ${formatCount(workload.complexMacCount)} complex MAC · steering ${formatNumber(workload.distanceMiB, 1)} MiB`; `약 ${formatCount(workload.complexMacCount)} complex MAC · ` +
`signal ≤ ${formatCount(signalPathBudget)} paths · steering ${formatNumber(workload.distanceMiB, 1)} MiB`;
const heavyCpu = state.experiment.beamforming.backend === "cpu" && workload.complexMacCount > 5e8; const heavyCpu = state.experiment.beamforming.backend === "cpu" && workload.complexMacCount > 5e8;
elements.workloadSummary.classList.toggle("is-warning", heavyCpu || workload.pointCount >= 40000); elements.workloadSummary.classList.toggle(
"is-warning",
heavyCpu || workload.pointCount >= 40000 || signalPathBudget > 100000,
);
} catch (error) { } catch (error) {
elements.workloadSummary.textContent = `연산량을 계산할 수 없습니다 · ${error.message}`; elements.workloadSummary.textContent = `연산량을 계산할 수 없습니다 · ${error.message}`;
elements.workloadSummary.classList.add("is-warning"); elements.workloadSummary.classList.add("is-warning");
@@ -646,6 +689,15 @@ function renderResult() {
`${formatNumber(displayed.run.timingMs.signalGeneration, 0)} ms`, `${formatNumber(displayed.run.timingMs.signalGeneration, 0)} ms`,
`${displayed.snapshot.arrayDefinition.microphones.length} channels`, `${displayed.snapshot.arrayDefinition.microphones.length} channels`,
); );
if (displayed.run.signalStats.acousticModel) {
const modelLabel =
displayed.run.signalStats.acousticModel === "shoebox" ? "SPARSE ROOM" : "FREE FIELD";
appendMetric(
"음향 모델",
modelLabel,
`${(displayed.run.signalStats.acousticPathCount ?? 0).toLocaleString("ko-KR")} paths 합성`,
);
}
appendMetric( appendMetric(
"Beamforming", "Beamforming",
`${formatNumber(displayed.run.timingMs.beamforming, 0)} ms`, `${formatNumber(displayed.run.timingMs.beamforming, 0)} ms`,
@@ -1475,6 +1527,21 @@ function installDocumentInputListeners() {
state.scene.seed = value; state.scene.seed = value;
markChanged({ dirty: "scene" }); markChanged({ dirty: "scene" });
}); });
elements.acousticProfile.addEventListener("change", () => {
if (elements.acousticProfile.value === "custom") return syncControls();
applyAcousticProfile(
state.scene,
state.arrayDefinition,
state.experiment,
elements.acousticProfile.value,
);
markChanged({ dirty: "scene", syncControls: true });
});
elements.frequencyResponseMode.addEventListener("change", () => {
state.experiment.beamforming.compensateFrequencyResponse =
elements.frequencyResponseMode.value === "compensate";
markChanged();
});
elements.backend.addEventListener("change", () => { elements.backend.addEventListener("change", () => {
state.experiment.beamforming.backend = elements.backend.value; state.experiment.beamforming.backend = elements.backend.value;
markChanged(); markChanged();
+17
View File
@@ -67,6 +67,14 @@ export function shiftSceneDistance(scene, experiment, nextDistance) {
for (const keyframe of source.trajectory ?? []) keyframe.position[2] += delta; for (const keyframe of source.trajectory ?? []) keyframe.position[2] += delta;
} }
if (scene.image?.plane) scene.image.plane.z += delta; if (scene.image?.plane) scene.image.plane.z += delta;
if (scene.acoustics?.room) {
scene.acoustics.room.min[2] += delta;
scene.acoustics.room.max[2] += delta;
}
for (const occluder of scene.acoustics?.occluders ?? []) {
occluder.min[2] += delta;
occluder.max[2] += delta;
}
} }
function sourceGeometryPositions(scene) { function sourceGeometryPositions(scene) {
@@ -225,6 +233,15 @@ export function createResultDocument(options) {
preNormalizationPeak: run.signalStats.preNormalizationPeak, preNormalizationPeak: run.signalStats.preNormalizationPeak,
appliedScale: run.signalStats.appliedScale, appliedScale: run.signalStats.appliedScale,
clippedSampleCount: run.signalStats.clippedSampleCount, clippedSampleCount: run.signalStats.clippedSampleCount,
...(run.signalStats.acousticModel
? { acousticModel: run.signalStats.acousticModel }
: {}),
...(Number.isInteger(run.signalStats.acousticPathCount)
? { acousticPathCount: run.signalStats.acousticPathCount }
: {}),
...(run.signalStats.acousticPathCounts
? { acousticPathCounts: { ...run.signalStats.acousticPathCounts } }
: {}),
}, },
timingMs: { timingMs: {
total: run.timingMs.total, total: run.timingMs.total,
+4 -1
View File
@@ -24,6 +24,9 @@ function signalStats(synthesis) {
appliedScale: synthesis.appliedScale, appliedScale: synthesis.appliedScale,
clippedSampleCount: synthesis.clippedSampleCount, clippedSampleCount: synthesis.clippedSampleCount,
peak: synthesis.peak, peak: synthesis.peak,
acousticModel: synthesis.acousticModel,
acousticPathCount: synthesis.acousticPathCount,
acousticPathCounts: synthesis.acousticPathCounts,
}; };
} }
@@ -167,7 +170,7 @@ self.addEventListener("message", async (event) => {
await runTimeline(event.data); await runTimeline(event.data);
return; return;
} }
post("progress", jobId, { stage: "signal", message: "192채널 자유음장 신호를 합성하고 있습니다…" }); post("progress", jobId, { stage: "signal", message: "마이크 수신 신호를 합성하고 있습니다…" });
const signalStartedAt = performance.now(); const signalStartedAt = performance.now();
const synthesis = synthesizeMicrophoneSignals({ const synthesis = synthesizeMicrophoneSignals({
scene, scene,
+6
View File
@@ -224,6 +224,12 @@
font: 600 .6rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; font: 600 .6rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
} }
.workload-summary.is-warning { color: var(--amber); } .workload-summary.is-warning { color: var(--amber); }
.acoustic-environment-summary {
margin: 8px 0 0;
color: var(--muted);
font-size: .6rem;
line-height: 1.5;
}
.mini-button, .text-button { .mini-button, .text-button {
padding: 5px 7px; padding: 5px 7px;
+13 -1
View File
@@ -23,7 +23,19 @@
"properties": { "properties": {
"preNormalizationPeak": { "type": "number", "minimum": 0 }, "preNormalizationPeak": { "type": "number", "minimum": 0 },
"appliedScale": { "type": "number", "minimum": 0 }, "appliedScale": { "type": "number", "minimum": 0 },
"clippedSampleCount": { "type": "integer", "minimum": 0 } "clippedSampleCount": { "type": "integer", "minimum": 0 },
"acousticModel": { "enum": ["free-field", "shoebox"] },
"acousticPathCount": { "type": "integer", "minimum": 0 },
"acousticPathCounts": {
"type": "object", "additionalProperties": false,
"properties": {
"direct": { "type": "integer", "minimum": 0 },
"reflection": { "type": "integer", "minimum": 0 },
"scattering": { "type": "integer", "minimum": 0 },
"diffraction": { "type": "integer", "minimum": 0 },
"diffuse": { "type": "integer", "minimum": 0 }
}
}
} }
}, },
"timingMs": { "timingMs": {
+12 -1
View File
@@ -76,6 +76,16 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
assert(!documentObject.querySelector("#advanced-settings").open, "advanced settings start collapsed"); assert(!documentObject.querySelector("#advanced-settings").open, "advanced settings start collapsed");
assert(!documentObject.querySelector("#array-details").open, "array developer detail starts collapsed"); assert(!documentObject.querySelector("#array-details").open, "array developer detail starts collapsed");
assert(!documentObject.querySelector("#camera-help").open, "limitations help starts collapsed"); assert(!documentObject.querySelector("#camera-help").open, "limitations help starts collapsed");
assertEqual(documentObject.querySelector("#acoustic-profile").value, "free-field", "default free-field profile");
assertEqual(
documentObject.querySelector("#frequency-response-mode").value,
"compensate",
"known microphone response is compensated by default",
);
assert(
documentObject.querySelector("#acoustic-environment-summary").textContent.includes("sparse path"),
"bounded acoustic cost is visible",
);
const canvas = documentObject.querySelector("#scene-canvas"); const canvas = documentObject.querySelector("#scene-canvas");
await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더"); await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더");
@@ -107,7 +117,8 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
); );
assertEqual(documentObject.documentElement.dataset.runBackend, "cpu", "actual backend"); assertEqual(documentObject.documentElement.dataset.runBackend, "cpu", "actual backend");
assertEqual(documentObject.documentElement.dataset.resultBandCount, "1", "result band count"); assertEqual(documentObject.documentElement.dataset.resultBandCount, "1", "result band count");
assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 9, "result metrics rendered"); assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 10, "result metrics rendered");
assert(documentObject.querySelector("#metric-grid").textContent.includes("음향 모델"), "acoustic model metric rendered");
assert(!documentObject.querySelector("#metric-grid").classList.contains("is-hidden"), "metrics visible"); assert(!documentObject.querySelector("#metric-grid").classList.contains("is-hidden"), "metrics visible");
assert(documentObject.querySelector("#result-badge").textContent.includes("CPU"), "backend badge rendered"); assert(documentObject.querySelector("#result-badge").textContent.includes("CPU"), "backend badge rendered");
assert(documentObject.documentElement.dataset.unsavedWork.includes("Result"), "completed Result is unsaved"); assert(documentObject.documentElement.dataset.unsavedWork.includes("Result"), "completed Result is unsaved");
+67 -1
View File
@@ -4,6 +4,12 @@ import {
sceneHasTrajectories, sceneHasTrajectories,
scenePlaybackState, scenePlaybackState,
} from "/apps/acoustic-camera/timeline.js"; } from "/apps/acoustic-camera/timeline.js";
import {
applyAcousticProfile,
detectAcousticProfile,
inferShoeboxRoom,
maximumSparsePathsPerReceiver,
} from "/apps/acoustic-camera/acoustic-profiles.js";
import { import {
alignExperimentToScene, alignExperimentToScene,
@@ -246,6 +252,41 @@ test("Camera workload counts unique overlapping FFT bins", () => {
assertNear(workload.distanceMiB, (80 * 3 * 4) / 1024 ** 2, 1e-15, "distance table size"); assertNear(workload.distanceMiB, (80 * 3 * 4) / 1024 ** 2, 1e-15, "distance table size");
}); });
test("Camera acoustic presets bound current geometry and expose predictable sparse cost", () => {
const scene = {
sources: [{
id: "moving",
position: [0, 0, 2],
trajectory: [
{ time: 0, position: [-0.5, 0, 2] },
{ time: 1, position: [0.75, 0.5, 3] },
],
}],
};
const arrayDefinition = {
microphones: [
{ id: "left", position: [-0.2, 0, 0] },
{ id: "right", position: [0.2, 0, 0] },
],
};
const experiment = {
scan: { xMin: -1, xMax: 1, yMin: -0.75, yMax: 0.75, z: 2.5 },
};
const room = inferShoeboxRoom(scene, arrayDefinition, experiment);
assert(room.min[0] < -1 && room.max[0] > 1, "scan x is padded");
assert(room.min[2] < 0 && room.max[2] > 3, "array and trajectory depth are padded");
applyAcousticProfile(scene, arrayDefinition, experiment, "typical-room");
assertEqual(detectAcousticProfile(scene), "typical-room", "profile round trip");
assertEqual(maximumSparsePathsPerReceiver(scene), 19, "bounded typical-room path count");
assertEqual(scene.acoustics.room.min.join(","), room.min.join(","), "inferred room minimum");
assertEqual(scene.acoustics.room.max.join(","), room.max.join(","), "inferred room maximum");
applyAcousticProfile(scene, arrayDefinition, experiment, "free-field");
assertEqual(detectAcousticProfile(scene), "free-field", "free-field selection");
assertEqual(maximumSparsePathsPerReceiver(scene), 1, "free-field path count");
});
test("Camera distance shift keeps the scene and scan on one z plane", () => { test("Camera distance shift keeps the scene and scan on one z plane", () => {
const scene = { const scene = {
sources: [ sources: [
@@ -260,6 +301,17 @@ test("Camera distance shift keeps the scene and scan on one z plane", () => {
{ id: "two", position: [-0.3, 0.4, 2.5] }, { id: "two", position: [-0.3, 0.4, 2.5] },
], ],
image: { plane: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1 } }, image: { plane: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1 } },
acoustics: {
model: "shoebox",
room: { min: [-2, -2, -0.5], max: [2, 2, 4] },
occluders: [{
id: "screen",
min: [-0.2, -0.2, 1],
max: [0.2, 0.2, 1.1],
transmissionDb: -20,
diffractionGainDb: -12,
}],
},
}; };
const experiment = { scan: { z: 2 } }; const experiment = { scan: { z: 2 } };
@@ -279,6 +331,10 @@ test("Camera distance shift keeps the scene and scan on one z plane", () => {
); );
assertEqual(scene.sources[1].position.join(","), "-0.3,0.4,3.75", "relative source depth retained"); assertEqual(scene.sources[1].position.join(","), "-0.3,0.4,3.75", "relative source depth retained");
assertEqual(scene.image.plane.z, 3.25, "background image plane z shift"); assertEqual(scene.image.plane.z, 3.25, "background image plane z shift");
assertEqual(scene.acoustics.room.min[2], 0.75, "room minimum z shift");
assertEqual(scene.acoustics.room.max[2], 5.25, "room maximum z shift");
assertEqual(scene.acoustics.occluders[0].min[2], 2.25, "occluder minimum z shift");
assertEqual(scene.acoustics.occluders[0].max[2], 2.35, "occluder maximum z shift");
}); });
test("Camera aligns an experiment to an imported scene without changing grid resolution", () => { test("Camera aligns an experiment to an imported scene without changing grid resolution", () => {
@@ -364,7 +420,14 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
}; };
const run = { const run = {
backend: "cpu", backend: "cpu",
signalStats: { preNormalizationPeak: 0.75, appliedScale: 1, clippedSampleCount: 0 }, signalStats: {
preNormalizationPeak: 0.75,
appliedScale: 1,
clippedSampleCount: 0,
acousticModel: "shoebox",
acousticPathCount: 38,
acousticPathCounts: { direct: 2, reflection: 12, scattering: 12, diffuse: 12 },
},
timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 }, timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 },
fallback: { code: "webgpu-unavailable", message: "CPU fallback" }, fallback: { code: "webgpu-unavailable", message: "CPU fallback" },
bands: [{ bands: [{
@@ -445,6 +508,9 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
assertEqual(result.bands[0].evaluation.matchToleranceM, 0.2, "evaluation settings are exported"); assertEqual(result.bands[0].evaluation.matchToleranceM, 0.2, "evaluation settings are exported");
assertEqual(result.environment.scanXMin, -1, "scan bounds are recorded"); assertEqual(result.environment.scanXMin, -1, "scan bounds are recorded");
assertEqual(result.environment.fallbackCode, "webgpu-unavailable", "fallback is recorded"); assertEqual(result.environment.fallbackCode, "webgpu-unavailable", "fallback is recorded");
assertEqual(result.signalStats.acousticModel, "shoebox", "acoustic model is exported");
assertEqual(result.signalStats.acousticPathCount, 38, "acoustic path count is exported");
assertEqual(result.signalStats.acousticPathCounts.diffuse, 12, "path kinds are exported");
const issues = await validateResult(result, { experiment, scene }); const issues = await validateResult(result, { experiment, scene });
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`); assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);