feat: add physically explicit narrowband MUSIC

This commit is contained in:
2026-08-29 12:40:48 +09:00
parent 044be3cf92
commit 11fee76280
40 changed files with 6473 additions and 275 deletions
+309 -64
View File
@@ -53,6 +53,14 @@ const MOVING_EXAMPLE_URLS = {
scene: "/assets/sample-scenes/moving-tone.scene.json",
experiment: "/assets/sample-scenes/moving-tone.experiment.json",
};
const MUSIC_EXAMPLE_URLS = {
arrayDefinition:
"/assets/array-geometries/rectangular-4x3-450mm.array.json",
scene:
"/assets/sample-scenes/music-single-tone.scene.json",
experiment:
"/assets/sample-scenes/music-single-tone.experiment.json",
};
const ARRAY_PRESET_URLS = new Map([
["rectangular-16x12", "/assets/array-geometries/rectangular-16x12.array.json"],
["fermat-spiral-192", "/assets/array-geometries/fermat-spiral-192.array.json"],
@@ -91,6 +99,7 @@ const elements = {
livePanel: document.querySelector(".live-panel"),
liveStatus: byId("live-status"),
loadMovingExampleButton: byId("load-moving-example-button"),
loadMusicExampleButton: byId("load-music-example-button"),
prepareLiveButton: byId("prepare-live-button"),
livePlayButton: byId("live-play-button"),
liveTime: byId("live-time"),
@@ -119,6 +128,10 @@ const elements = {
frequencyResponseMode: byId("frequency-response-mode"),
algorithm: byId("algorithm"),
backend: byId("backend"),
musicOptions: byId("music-options"),
musicSourceCountMethod: byId("music-source-count-method"),
musicSourceCount: byId("music-source-count"),
musicOptionsNote: byId("music-options-note"),
gridPreset: byId("grid-preset"),
scanColumns: byId("scan-columns"),
scanRows: byId("scan-rows"),
@@ -217,6 +230,10 @@ function applyExperimentDefaults(experiment) {
if (experiment.evaluation.detectionMethod === undefined) {
experiment.evaluation.detectionMethod = "local-max";
}
if (experiment.beamforming?.algorithm === "music") {
experiment.beamforming.musicSourceCountMethod ??= "fixed";
experiment.beamforming.musicSourceCount ??= 1;
}
return experiment;
}
@@ -280,6 +297,7 @@ function renderLiveControls() {
const playing = Boolean(ready && state.live.playing);
const locked = Boolean(state.active) || state.pendingInputOperations > 0;
elements.loadMovingExampleButton.disabled = locked;
elements.loadMusicExampleButton.disabled = locked;
elements.prepareLiveButton.disabled = locked || !hasTrajectory || !state.validation.valid;
elements.livePlayButton.disabled = locked || !ready;
elements.liveTime.disabled = locked || !ready;
@@ -331,7 +349,8 @@ function setConfigurationLocked(locked) {
setEditableControlsDisabled(locked);
elements.runButton.disabled = locked || !state.validation.valid;
elements.cancelButton.classList.toggle("is-hidden", !locked);
elements.exportResultButton.disabled = locked || !state.lastRun;
elements.exportResultButton.disabled =
locked || !state.lastRun?.resultDocument;
renderLiveControls();
}
@@ -407,6 +426,27 @@ function syncAlgorithmBackendControls() {
elements.backend.title = supportsWebGpu
? "지원되는 GPU를 우선 사용하고 필요하면 CPU로 전환합니다."
: `${definition?.label ?? algorithm}는 Auto와 CPU만 지원합니다.`;
const music = algorithm === "music";
const sourceCountMethod =
state.experiment?.beamforming?.musicSourceCountMethod ?? "fixed";
const maximumSourceCount = Math.max(
1,
(state.arrayDefinition?.microphones?.length ?? 2) - 1,
);
elements.musicOptions.hidden = !music;
elements.musicSourceCountMethod.value = sourceCountMethod;
elements.musicSourceCount.max = String(maximumSourceCount);
elements.musicSourceCount.value = String(
state.experiment?.beamforming?.musicSourceCount ?? 1,
);
elements.musicSourceCount.disabled = sourceCountMethod !== "fixed";
elements.musicOptionsNote.textContent = sourceCountMethod === "fixed"
? `각 band는 아날로그 FFT-grid 가설과 ADC bin을 각각 하나만 선택합니다. K는 1–${maximumSourceCount} 범위입니다. 같은 주파수·고정 위상의 coherent 음원은 standard MUSIC으로 분리되지 않으며 raw q는 확률이 아닙니다.`
: "MDL/AIC는 IID complex-Gaussian snapshot·spatially white noise·일정한 source count를 가정합니다. 비중첩 frame은 독립성의 증명이 아니며, 결과 K는 확률/신뢰도가 아닙니다.";
elements.showBeamSpread.disabled = music;
elements.showBeamSpread.title = music
? "MUSIC noise-projection에는 DAS/PHAT의 half-power beam spread를 적용하지 않습니다."
: "점선은 1× RMS, 실선 영역은 2× RMS 빔 응답 분포입니다. 확률 또는 신뢰구간은 아닙니다.";
document.documentElement.dataset.localizationAlgorithm = algorithm ?? "";
}
@@ -664,9 +704,9 @@ function renderLegend() {
`${formatNumber(range.lastBinCenterHz, 0)} Hz`)
.join(", ");
text.textContent = `${requested} → ADC FFT bin centers ${adcRanges}`;
text.title =
"Ideal sampler, no anti-alias filter. Localization uses the observed ADC bins; " +
"the original alias order is not silently used for steering.";
text.title = state.experiment.beamforming.algorithm === "music"
? "MUSIC uses the single analog FFT-grid hypothesis configured by this band, never Scene truth. Inverted Nyquist zones conjugate the full received manifold."
: "Ideal sampler, no anti-alias filter. Localization uses the observed ADC bins; the original alias order is not silently used for steering.";
} else {
text.textContent = requested;
}
@@ -729,13 +769,17 @@ function renderLegend() {
const localizationDescription =
state.experiment.beamforming.algorithm === "gcc-phat"
? "관측된 one-sided ADC FFT bin만 사용해 band-limited GCC-PHAT 기하 lag를 계산하며 "
: state.experiment.beamforming.algorithm === "music"
? "관측 ADC bin의 covariance와 band가 명시한 단일 analog carrier manifold로 standard narrowband MUSIC q를 계산하며 "
: "관측된 양의 ADC FFT bin 주파수로 구면 steering하며 ";
const carrierDescription =
state.experiment.beamforming.algorithm === "music"
? "Scene truth는 사용하지 않습니다. 반전 Nyquist zone은 마이크 응답과 공간 위상을 모두 켤레 처리하며, 물리적 grating lobe·동률·모델 불일치는 그대로 유지합니다. 한 band의 복수 carrier 가설은 선택하거나 융합하지 않고 거부합니다. "
: "원래 analog carrier를 사용하지 않습니다. 공간 위상 불일치와 grating lobe는 그대로 나타나며 alias order는 미해결 상태입니다. "
elements.samplingWarning.textContent =
"Ideal ADC alias · 위치 추정은 " + localizationDescription +
"원래 analog carrier를 사용하지 않습니다. 공간 위상 불일치와 grating lobe는 " +
"그대로 나타나며, 현재 알고리즘에서는 alias order가 미해결 상태로 남습니다. " +
"다채널 공간 위상에는 carrier-order 증거가 남을 수 있지만 이 localizer는 " +
"carrier hypothesis를 열거하지 않습니다. " +
"Ideal ADC alias · 위치 추정은 " +
localizationDescription +
carrierDescription +
details.join(" · ");
elements.samplingWarning.hidden = false;
}
@@ -752,8 +796,13 @@ function renderWorkload() {
? `${workload.referencePairCount} ref pairs · 약 ${formatCount(workload.scanOperationCount)} scan lookups + ` +
`${formatCount(workload.gccCrossSpectrumComplexOps)} cross-spectrum ops + ` +
`${formatCount(workload.inverseFftTransformCount)} inverse FFTs`
: `${workload.frameCount} frames × ${workload.selectedBinCount} bins · ` +
`약 ${formatCount(workload.scanOperationCount)} complex MAC`;
: workload.algorithm === "music"
? `${workload.frameCount} snapshots × ${workload.musicHypothesisCount} analog hypotheses / ${workload.selectedBinCount} ADC bins · ` +
`cov ${formatCount(workload.musicCovarianceComplexAccumulations)} accumulations + ` +
`Jacobi ≤ ${formatCount(workload.musicJacobiWorstCasePairUpdates)} / cap ${formatCount(workload.musicJacobiPairUpdateLimit)} pair updates (32 sweeps) + ` +
`projection ≤ ${formatCount(workload.musicProjectionComplexMacs)} complex MAC`
: `${workload.frameCount} frames × ${workload.selectedBinCount} bins · ` +
`약 ${formatCount(workload.scanOperationCount)} complex MAC`;
elements.workloadSummary.textContent =
`${workload.algorithmLabel} · ${workload.pointCount.toLocaleString("ko-KR")} points × ` +
`${workload.microphoneCount} ch · STFT ${formatCount(workload.stftTransformCount)} transforms · ` +
@@ -762,7 +811,14 @@ function renderWorkload() {
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
const heavyCpu =
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
workload.scanOperationCount > 5e8;
(
workload.scanOperationCount > 5e8 ||
(
workload.algorithm === "music" &&
workload.musicJacobiWorstCasePairUpdates >
150_000_000
)
);
const narrowGcc = workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8;
elements.workloadSummary.classList.toggle(
"is-warning",
@@ -792,7 +848,11 @@ function bandsForVisualization(lastRun) {
function interpolateMap(left, right, progress) {
if (left === right || !(progress > 0)) return left;
if (!left || !right || left.length !== right.length) return progress < 0.5 ? left : right;
const values = new Float32Array(left.length);
const MapArray =
left instanceof Float64Array || right instanceof Float64Array
? Float64Array
: Float32Array;
const values = new MapArray(left.length);
const inverse = 1 - progress;
for (let index = 0; index < values.length; index += 1) {
values[index] = left[index] * inverse + right[index] * progress;
@@ -818,7 +878,13 @@ function liveVisualizationState(live) {
visible: state.visibleBandIds.has(leftBand.id),
};
});
return { playback, scene: playback.scene, bands, evaluations: nearestFrame.evaluations };
return {
playback,
scene: playback.scene,
bands,
evaluations: nearestFrame.evaluations,
run: nearestFrame.run,
};
}
function renderSceneVisualization() {
@@ -843,7 +909,12 @@ function renderSceneVisualization() {
);
elements.canvasBadge.textContent =
`LIVE · ${algorithmLabel} · ${formatNumber(live.time, 2)} s · map ${visualization.playback.framePosition.toFixed(1)}/` +
`${Math.max(0, live.frames.length - 1)} · ${live.backend.toUpperCase()}`;
`${Math.max(0, live.frames.length - 1)} · ${live.backend.toUpperCase()}` +
(
visualization.run.status === "unavailable"
? ` · 위치 없음 (${visualization.run.unavailable?.reason ?? "unavailable"})`
: ""
);
return;
}
const displayed = state.lastRun;
@@ -862,6 +933,9 @@ function renderSceneVisualization() {
elements.canvasMessage.classList.toggle("is-hidden", Boolean(displayed || assetRuntime.imageElement));
if (!displayed) {
elements.canvasBadge.textContent = "결과 없음";
} else if (displayed.run.status === "unavailable") {
elements.canvasBadge.textContent =
`MUSIC · 위치 없음 · ${displayed.run.unavailable?.reason ?? "unavailable"}`;
} else if (displayed.stale) {
elements.canvasBadge.textContent =
`이전 설정 결과 · ${localizationAlgorithmLabel(displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm)} · ` +
@@ -890,7 +964,8 @@ function appendMetric(label, value, detail, color) {
function renderResult() {
const displayed = state.lastRun;
elements.exportResultButton.disabled = Boolean(state.active) || !displayed;
elements.exportResultButton.disabled =
Boolean(state.active) || !displayed?.resultDocument;
elements.metricGrid.replaceChildren();
elements.emptyResult.classList.toggle("is-hidden", Boolean(displayed));
elements.metricGrid.classList.toggle("is-hidden", !displayed);
@@ -898,6 +973,26 @@ function renderResult() {
elements.resultBadge.textContent = "아직 실행하지 않음";
return;
}
if (displayed.run.status === "unavailable") {
const algorithmLabel = localizationAlgorithmLabel(
displayed.run.algorithm ??
displayed.snapshot.experiment.beamforming.algorithm,
);
elements.resultBadge.textContent =
`${algorithmLabel} · 위치 없음`;
appendMetric(
"위치 추정",
"UNAVAILABLE",
displayed.run.unavailable?.reason ??
"spatially-unidentifiable",
);
appendMetric(
"처리 상태",
"정상 소비",
"임의 map·위치를 만들지 않았습니다.",
);
return;
}
const algorithm = displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm;
const algorithmLabel = localizationAlgorithmLabel(algorithm);
elements.resultBadge.textContent = displayed.stale
@@ -968,8 +1063,12 @@ function renderResult() {
!observation.gridManyToOne &&
toneContributions.length === 0
) continue;
const resultAlgorithm =
displayed.snapshot.experiment.beamforming.algorithm;
const localizationDetail =
displayed.snapshot.experiment.beamforming.algorithm === "gcc-phat"
resultAlgorithm === "music"
? "협대역 MUSIC noise subspace · configured analog carrier · inversion-aware · Scene truth 미사용"
: resultAlgorithm === "gcc-phat"
? "band-limited GCC-PHAT · 원 carrier 미사용"
: "양의 ADC bin steering · 원 carrier 미사용 · alias order 미해결";
const toneDetail = toneContributions.length > 0
@@ -977,9 +1076,11 @@ function renderResult() {
: "";
appendMetric(
`${band.id} · ADC steering`,
displayed.snapshot.experiment.beamforming.algorithm === "gcc-phat"
resultAlgorithm === "gcc-phat"
? "ADC LAG"
: "OBSERVED BIN",
: resultAlgorithm === "music"
? "MIN q"
: "OBSERVED BIN",
localizationDetail + toneDetail,
band.color,
);
@@ -995,7 +1096,9 @@ function renderResult() {
appendMetric(
"Peak 검출 임계값",
`${formatNumber(detectionThreshold * 100, 0)}%`,
"최강 map 값 기준 · 낮을수록 민감",
algorithm === "music"
? "표준 MUSIC P=1/q의 P/Pmax 기준 · raw q 불변 · floor 없음"
: "최강 map 값 기준 · 낮을수록 민감",
);
appendMetric(
"Peak 묶기",
@@ -1015,32 +1118,45 @@ function renderResult() {
`${evaluation.counts.truePositive}/${evaluation.counts.truth} source`,
band.color,
);
appendMetric(
`${band.id} · Truth concentration`,
`${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`,
"ground-truth 반경 내",
band.color,
);
appendMetric(
`${band.id} · Peak/sidelobe`,
`${formatNumber(evaluation.metrics.peakToSidelobeDb, 1)} dB`,
"높을수록 분리 우수",
band.color,
);
if (evaluation.diagnostics.objectiveDirection === "minimum") {
appendMetric(
`${band.id} · Noise projection q`,
evaluation.globalPeak
? formatNumber(evaluation.globalPeak.value, 6)
: "식별 불가",
"작을수록 MUSIC 후보 · 확률/신뢰도 아님 · raw q",
band.color,
);
} else {
appendMetric(
`${band.id} · Truth concentration`,
`${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`,
"ground-truth 반경 내",
band.color,
);
appendMetric(
`${band.id} · Peak/sidelobe`,
`${formatNumber(evaluation.metrics.peakToSidelobeDb, 1)} dB`,
"높을수록 분리 우수",
band.color,
);
}
appendMetric(
`${band.id} · Precision / Recall`,
`${formatNumber(evaluation.metrics.precision, 2)} / ${formatNumber(evaluation.metrics.recall, 2)}`,
`${evaluation.counts.detected} peaks`,
band.color,
);
appendMetric(
`${band.id} · Main lobe`,
`${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`,
evaluation.diagnostics.mapScale === "score"
? "half-maximum score diameter"
: "half-power equivalent diameter",
band.color,
);
if (evaluation.diagnostics.objectiveDirection === "maximum") {
appendMetric(
`${band.id} · Main lobe`,
`${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`,
evaluation.diagnostics.mapScale === "score"
? "half-maximum score diameter"
: "half-power equivalent diameter",
band.color,
);
}
const spread = evaluation.detections?.find((detection) => detection.beamSpread)?.beamSpread;
if (spread?.ellipse) {
appendMetric(
@@ -1109,6 +1225,15 @@ function validationWarnings() {
if (workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8) {
warnings.push("GCC-PHAT는 안정적인 광대역 상관을 위해 선택 FFT bin을 8개 이상 권장합니다.");
}
if (
workload.algorithm === "music" &&
workload.musicJacobiWorstCasePairUpdates >
150_000_000
) {
warnings.push(
`MUSIC Jacobi 상한이 큽니다 (${workload.musicJacobiWorstCasePairUpdates.toLocaleString()} / hard cap ${workload.musicJacobiPairUpdateLimit.toLocaleString()} pair updates). 결과를 단순화하지 않으므로 채널 수나 명시 carrier band 수를 줄이지 않으면 오래 걸릴 수 있습니다.`,
);
}
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
if (
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
@@ -1355,6 +1480,35 @@ async function runSimulation() {
assetRuntime.transferListForAudio(assetBuffers),
);
if (activity.canceled || state.active !== activity) throw createAbortError();
if (run.status === "unavailable") {
const inputHash = await inputHashPromise;
state.lastRun = {
snapshot,
run,
evaluations: new Map(),
exportedEvaluations: new Map(),
inputHash,
resultDocument: null,
stale: state.revision !== activity.revision,
imageElement: assetRuntime.imageElement,
visibleBandIds: new Set(state.visibleBandIds),
};
state.resultDirty = false;
document.documentElement.dataset.runState =
"unavailable";
document.documentElement.dataset.runBackend =
run.backend;
document.documentElement.dataset.runAlgorithm =
"music";
document.documentElement.dataset.resultBandCount =
"0";
setRunStatus(
`MUSIC 위치 없음 · ${run.unavailable?.reason ?? "unavailable"} · 임의 map을 만들지 않았습니다.`,
);
renderResult();
renderSceneVisualization();
return;
}
setRunStatus("Peak 검출과 정량 지표를 계산하고 있습니다…");
const evaluations = new Map();
const exportedEvaluations = new Map();
@@ -1368,6 +1522,7 @@ async function runSimulation() {
band.rawScale ??
localizationAlgorithmDefinition(run.algorithm ?? snapshot.experiment.beamforming.algorithm)?.rawScale ??
"power",
objectiveDirection: band.objectiveDirection,
scan: snapshot.experiment.scan,
sources: sourcesForObservedBand(
snapshot.scene,
@@ -1509,6 +1664,7 @@ function evaluateLiveFrames(snapshot, plan, frames) {
frame.run.algorithm ?? snapshot.experiment.beamforming.algorithm,
)?.rawScale ??
"power",
objectiveDirection: band.objectiveDirection,
scan: snapshot.experiment.scan,
sources: sourcesForObservedBand(
frameScene,
@@ -1625,9 +1781,17 @@ async function prepareLiveSimulation() {
playbackStartedAt: null,
playbackStartedTime: 0,
};
const unavailableFrameCount = timeline.frames.filter(
(frame) => frame.run.status === "unavailable",
).length;
setRunStatus(
`라이브 준비 완료 · ${timeline.frames.length} maps · ` +
`${formatNumber(timeline.timingMs.total, 0)} ms`,
`${formatNumber(timeline.timingMs.total, 0)} ms` +
(
unavailableFrameCount > 0
? ` · 위치 없음 ${unavailableFrameCount} frames`
: ""
),
);
renderLiveControls();
renderSceneVisualization();
@@ -1739,6 +1903,82 @@ async function loadMovingExample() {
}
}
async function loadMusicExample() {
if (state.active) return;
const bandCount =
state.experiment?.frequencyBands?.length ?? 0;
if (
(bandCount > 1 || hasUnsavedWork()) &&
!window.confirm(
"MUSIC 빠른 예제는 현재 Scene·Array·Experiment 전체와 " +
String(bandCount) +
"개 분석 대역을 교체합니다. 계속할까요?",
)
) {
setRunStatus(
"MUSIC 예제를 열지 않았습니다. 현재 입력을 유지합니다.",
);
return;
}
beginInputOperation();
try {
setRunStatus(
"12채널 표준 MUSIC 예제를 불러오고 있습니다…",
);
const [arrayDefinition, scene, experiment] =
await Promise.all([
loadJson(MUSIC_EXAMPLE_URLS.arrayDefinition),
loadJson(MUSIC_EXAMPLE_URLS.scene),
loadJson(MUSIC_EXAMPLE_URLS.experiment),
]);
const [arrayIssues, sceneIssues] =
await Promise.all([
validateArrayDefinition(arrayDefinition),
validateScene(scene),
]);
const experimentIssues = await validateExperiment(
experiment,
{ arrayDefinition, scene },
);
const issues = [
...arrayIssues,
...sceneIssues,
...experimentIssues,
];
if (issues.length > 0) {
throw new Error(
issues.slice(0, 4).map(formatIssue).join(" · "),
);
}
state.arrayDefinition = arrayDefinition;
state.scene = scene;
state.experiment =
applyExperimentDefaults(experiment);
state.sceneDirty = false;
state.experimentDirty = false;
state.visibleBandIds = new Set(
experiment.frequencyBands.map((band) => band.id),
);
assetRuntime.reset();
markChanged({
syncControls: true,
renderBands: true,
dirty: "none",
});
renderAssets();
setRunStatus(
"12채널·1.125 kHz·Fixed K=1 MUSIC 예제를 열었습니다. Snapshot 실행을 누르세요.",
);
} catch (error) {
setTransientError(
error,
"MUSIC 예제 불러오기 실패",
);
} finally {
endInputOperation();
}
}
function cancelSimulation() {
const activity = state.active;
if (!activity || activity.canceled) return;
@@ -1772,7 +2012,6 @@ async function loadArrayPreset(presetId) {
if (generation !== state.importGenerations.array) return;
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
state.arrayDefinition = value;
state.experiment.arrayId = value.id;
markChanged({ syncControls: true });
setRunStatus(`${value.name} 배열을 선택했습니다. 실행하면 같은 설정 기록에 추가됩니다.`);
} catch (error) {
@@ -1792,25 +2031,17 @@ async function importDocument(kind, input) {
try {
const value = await readJsonFile(file);
if (generation !== state.importGenerations[kind]) return;
let importedExperimentReferences = null;
let issues;
if (kind === "array") {
issues = await validateArrayDefinition(value);
} else if (kind === "scene") {
issues = await validateScene(value);
} else {
// Validate the imported document independently before changing any
// references. Then bind it to the open workspace and run the relationship
// checks as well, so import convenience does not weaken the contract.
// arrayId/sceneId are retained as document provenance, not treated as
// foreign keys. The currently open Array and Scene are the run inputs.
issues = await validateExperiment(value);
if (generation !== state.importGenerations[kind]) return;
if (issues.length === 0) {
importedExperimentReferences = {
sceneId: value.sceneId,
arrayId: value.arrayId,
};
value.sceneId = state.scene.id;
value.arrayId = state.arrayDefinition.id;
issues = await validateExperiment(value, {
arrayDefinition: state.arrayDefinition,
scene: state.scene,
@@ -1821,10 +2052,8 @@ async function importDocument(kind, input) {
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
if (kind === "array") {
state.arrayDefinition = value;
state.experiment.arrayId = value.id;
} else if (kind === "scene") {
state.scene = value;
state.experiment.sceneId = value.id;
alignExperimentToScene(state.experiment, value);
state.sceneDirty = false;
assetRuntime.reset();
@@ -1844,16 +2073,9 @@ async function importDocument(kind, input) {
: kind === "experiment"
? ` · 분석 대역 ${state.experiment.frequencyBands.length}개`
: "";
const referencesChanged = kind === "experiment" && (
importedExperimentReferences?.sceneId !== state.scene.id ||
importedExperimentReferences?.arrayId !== state.arrayDefinition.id
setRunStatus(
`${file.name} ${kind} 문서를 불러왔습니다.${bandStatus}`,
);
const referenceStatus = kind === "experiment"
? referencesChanged
? ` · 현재 Scene ${state.scene.id} · Array ${state.arrayDefinition.id}로 재연결`
: " · 현재 Scene · Array 연결 확인"
: "";
setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.${bandStatus}${referenceStatus}`);
} catch (error) {
setTransientError(error, `${file.name} 불러오기 실패`);
} finally {
@@ -1906,6 +2128,11 @@ function installDocumentInputListeners() {
elements.algorithm.addEventListener("change", () => {
const algorithm = elements.algorithm.value;
state.experiment.beamforming.algorithm = algorithm;
if (algorithm === "music") {
state.experiment.beamforming.musicSourceCountMethod ??= "fixed";
state.experiment.beamforming.musicSourceCount ??= 1;
state.experiment.beamforming.normalization = "none";
}
const backendReset =
state.experiment.beamforming.backend === "webgpu" &&
!localizationAlgorithmSupportsBackend(algorithm, "webgpu");
@@ -1915,6 +2142,20 @@ function installDocumentInputListeners() {
setRunStatus(`${localizationAlgorithmLabel(algorithm)}는 CPU 전용이므로 Backend를 Auto (CPU)로 바꿨습니다.`);
}
});
elements.musicSourceCountMethod.addEventListener("change", () => {
const method = elements.musicSourceCountMethod.value;
state.experiment.beamforming.musicSourceCountMethod = method;
if (method === "fixed") {
state.experiment.beamforming.musicSourceCount ??= 1;
}
markChanged({ syncControls: true });
});
elements.musicSourceCount.addEventListener("change", () => {
const value = readFiniteInput(elements.musicSourceCount);
if (value === null) return syncControls();
state.experiment.beamforming.musicSourceCount = Math.trunc(value);
markChanged({ syncControls: true });
});
elements.backend.addEventListener("change", () => {
if (
elements.backend.value === "webgpu" &&
@@ -2045,6 +2286,10 @@ function installActionListeners() {
elements.runButton.addEventListener("click", runSimulation);
elements.cancelButton.addEventListener("click", cancelSimulation);
elements.loadMovingExampleButton.addEventListener("click", loadMovingExample);
elements.loadMusicExampleButton.addEventListener(
"click",
loadMusicExample,
);
elements.prepareLiveButton.addEventListener("click", prepareLiveSimulation);
elements.livePlayButton.addEventListener("click", toggleLivePlayback);
elements.liveTime.addEventListener("input", () => {
@@ -2125,7 +2370,7 @@ function installActionListeners() {
setRunStatus("Experiment JSON을 저장했습니다.");
});
elements.exportResultButton.addEventListener("click", () => {
if (!state.lastRun) return;
if (!state.lastRun?.resultDocument) return;
downloadJson(
state.lastRun.resultDocument,
`${safeFileStem(state.lastRun.snapshot.experiment.id)}.result.json`,