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>
<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>
+69 -2
View File
@@ -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();
+17
View File
@@ -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,
+4 -1
View File
@@ -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,
+6
View File
@@ -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;