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
+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("#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");
+67 -1
View File
@@ -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)}`);