diff --git a/apps/acoustic-camera/acoustic-profiles.js b/apps/acoustic-camera/acoustic-profiles.js new file mode 100644 index 0000000..eedc802 --- /dev/null +++ b/apps/acoustic-camera/acoustic-profiles.js @@ -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; +} diff --git a/apps/acoustic-camera/index.html b/apps/acoustic-camera/index.html index 77b0b81..9a9ca23 100644 --- a/apps/acoustic-camera/index.html +++ b/apps/acoustic-camera/index.html @@ -81,7 +81,7 @@
- 고급 설정범위 · STFT · 표시 · 식별자 + 고급 설정음향 · 범위 · STFT · 식별자

실험과 환경

@@ -90,7 +90,10 @@ + +
+

음향 모델을 확인하고 있습니다.

Scan 범위

diff --git a/apps/acoustic-camera/main.js b/apps/acoustic-camera/main.js index c91d4b4..fe5b91f 100644 --- a/apps/acoustic-camera/main.js +++ b/apps/acoustic-camera/main.js @@ -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(); diff --git a/apps/acoustic-camera/model.js b/apps/acoustic-camera/model.js index cc640f9..f8cc7f7 100644 --- a/apps/acoustic-camera/model.js +++ b/apps/acoustic-camera/model.js @@ -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, diff --git a/apps/acoustic-camera/simulation-worker.js b/apps/acoustic-camera/simulation-worker.js index abb1da7..3a528a9 100644 --- a/apps/acoustic-camera/simulation-worker.js +++ b/apps/acoustic-camera/simulation-worker.js @@ -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, diff --git a/apps/acoustic-camera/styles.css b/apps/acoustic-camera/styles.css index d01435a..b5f771f 100644 --- a/apps/acoustic-camera/styles.css +++ b/apps/acoustic-camera/styles.css @@ -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; diff --git a/schemas/result.schema.json b/schemas/result.schema.json index 9f1ca87..96eff58 100644 --- a/schemas/result.schema.json +++ b/schemas/result.schema.json @@ -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": { diff --git a/tests/integration/camera-ui.test.js b/tests/integration/camera-ui.test.js index 1f7fd84..84bffc4 100644 --- a/tests/integration/camera-ui.test.js +++ b/tests/integration/camera-ui.test.js @@ -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"); diff --git a/tests/unit/camera.test.js b/tests/unit/camera.test.js index e324587..fe289bb 100644 --- a/tests/unit/camera.test.js +++ b/tests/unit/camera.test.js @@ -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)}`);