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
+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();