feat: expose acoustic realism controls
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -81,7 +81,7 @@
|
||||
</div>
|
||||
|
||||
<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-group">
|
||||
<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"><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 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>
|
||||
<p class="acoustic-environment-summary" id="acoustic-environment-summary">음향 모델을 확인하고 있습니다.</p>
|
||||
</div>
|
||||
<div class="advanced-group">
|
||||
<h3>Scan 범위</h3>
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
validateScene,
|
||||
} from "/packages/contracts/validation.js";
|
||||
import { createCameraAssetRuntime } from "./asset-runtime.js";
|
||||
import {
|
||||
applyAcousticProfile,
|
||||
detectAcousticProfile,
|
||||
maximumSparsePathsPerReceiver,
|
||||
} from "./acoustic-profiles.js";
|
||||
import {
|
||||
createAnalysisFramePlan,
|
||||
playbackFrameAtTime,
|
||||
@@ -90,6 +95,9 @@ const elements = {
|
||||
sceneDistance: byId("scene-distance"),
|
||||
speedOfSound: byId("speed-of-sound"),
|
||||
sceneSeed: byId("scene-seed"),
|
||||
acousticProfile: byId("acoustic-profile"),
|
||||
acousticEnvironmentSummary: byId("acoustic-environment-summary"),
|
||||
frequencyResponseMode: byId("frequency-response-mode"),
|
||||
backend: byId("backend"),
|
||||
gridPreset: byId("grid-preset"),
|
||||
scanColumns: byId("scan-columns"),
|
||||
@@ -358,6 +366,9 @@ function syncControls() {
|
||||
elements.sceneDistance.value = experiment.scan.z;
|
||||
elements.speedOfSound.value = scene.speedOfSound;
|
||||
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.scanColumns.value = experiment.scan.columns;
|
||||
elements.scanRows.value = experiment.scan.rows;
|
||||
@@ -381,6 +392,30 @@ function syncControls() {
|
||||
: "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) {
|
||||
if (signal.type === "tone") return `${formatNumber(signal.frequency, 0)} Hz tone`;
|
||||
if (signal.type === "noise") return `${signal.color} noise`;
|
||||
@@ -390,6 +425,7 @@ function describeSignal(signal) {
|
||||
function renderDocumentSummary() {
|
||||
const { arrayDefinition, scene, experiment } = state;
|
||||
if (!arrayDefinition || !scene || !experiment) return;
|
||||
renderAcousticSummary();
|
||||
elements.workspaceTitle.textContent = scene.name;
|
||||
elements.sceneMeta.textContent =
|
||||
`${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;
|
||||
try {
|
||||
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 =
|
||||
`${workload.pointCount.toLocaleString("ko-KR")} points × ${workload.microphoneCount} ch · ` +
|
||||
`${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;
|
||||
elements.workloadSummary.classList.toggle("is-warning", heavyCpu || workload.pointCount >= 40000);
|
||||
elements.workloadSummary.classList.toggle(
|
||||
"is-warning",
|
||||
heavyCpu || workload.pointCount >= 40000 || signalPathBudget > 100000,
|
||||
);
|
||||
} catch (error) {
|
||||
elements.workloadSummary.textContent = `연산량을 계산할 수 없습니다 · ${error.message}`;
|
||||
elements.workloadSummary.classList.add("is-warning");
|
||||
@@ -646,6 +689,15 @@ function renderResult() {
|
||||
`${formatNumber(displayed.run.timingMs.signalGeneration, 0)} ms`,
|
||||
`${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(
|
||||
"Beamforming",
|
||||
`${formatNumber(displayed.run.timingMs.beamforming, 0)} ms`,
|
||||
@@ -1475,6 +1527,21 @@ function installDocumentInputListeners() {
|
||||
state.scene.seed = value;
|
||||
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", () => {
|
||||
state.experiment.beamforming.backend = elements.backend.value;
|
||||
markChanged();
|
||||
|
||||
@@ -67,6 +67,14 @@ export function shiftSceneDistance(scene, experiment, nextDistance) {
|
||||
for (const keyframe of source.trajectory ?? []) keyframe.position[2] += 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) {
|
||||
@@ -225,6 +233,15 @@ export function createResultDocument(options) {
|
||||
preNormalizationPeak: run.signalStats.preNormalizationPeak,
|
||||
appliedScale: run.signalStats.appliedScale,
|
||||
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: {
|
||||
total: run.timingMs.total,
|
||||
|
||||
@@ -24,6 +24,9 @@ function signalStats(synthesis) {
|
||||
appliedScale: synthesis.appliedScale,
|
||||
clippedSampleCount: synthesis.clippedSampleCount,
|
||||
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);
|
||||
return;
|
||||
}
|
||||
post("progress", jobId, { stage: "signal", message: "192채널 자유음장 신호를 합성하고 있습니다…" });
|
||||
post("progress", jobId, { stage: "signal", message: "마이크 수신 신호를 합성하고 있습니다…" });
|
||||
const signalStartedAt = performance.now();
|
||||
const synthesis = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
|
||||
@@ -224,6 +224,12 @@
|
||||
font: 600 .6rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
.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 {
|
||||
padding: 5px 7px;
|
||||
|
||||
@@ -23,7 +23,19 @@
|
||||
"properties": {
|
||||
"preNormalizationPeak": { "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": {
|
||||
|
||||
@@ -76,6 +76,16 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
assert(!documentObject.querySelector("#advanced-settings").open, "advanced settings start collapsed");
|
||||
assert(!documentObject.querySelector("#array-details").open, "array developer detail 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");
|
||||
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.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("#result-badge").textContent.includes("CPU"), "backend badge rendered");
|
||||
assert(documentObject.documentElement.dataset.unsavedWork.includes("Result"), "completed Result is unsaved");
|
||||
|
||||
@@ -4,6 +4,12 @@ import {
|
||||
sceneHasTrajectories,
|
||||
scenePlaybackState,
|
||||
} from "/apps/acoustic-camera/timeline.js";
|
||||
import {
|
||||
applyAcousticProfile,
|
||||
detectAcousticProfile,
|
||||
inferShoeboxRoom,
|
||||
maximumSparsePathsPerReceiver,
|
||||
} from "/apps/acoustic-camera/acoustic-profiles.js";
|
||||
|
||||
import {
|
||||
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");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const scene = {
|
||||
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] },
|
||||
],
|
||||
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 } };
|
||||
|
||||
@@ -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.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", () => {
|
||||
@@ -364,7 +420,14 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
};
|
||||
const run = {
|
||||
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 },
|
||||
fallback: { code: "webgpu-unavailable", message: "CPU fallback" },
|
||||
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.environment.scanXMin, -1, "scan bounds are 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 });
|
||||
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
|
||||
|
||||
Reference in New Issue
Block a user