feat: add physically explicit narrowband MUSIC
This commit is contained in:
+309
-64
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user