diff --git a/apps/acoustic-camera/asset-runtime.js b/apps/acoustic-camera/asset-runtime.js
index 50833c0..a4cd0b4 100644
--- a/apps/acoustic-camera/asset-runtime.js
+++ b/apps/acoustic-camera/asset-runtime.js
@@ -191,6 +191,17 @@ export function createCameraAssetRuntime(onChange = () => {}) {
return statuses(scene).filter((status) => !status.ready).map(({ descriptor }) => descriptor.id);
}
+ function missingAudioAssetIds(scene) {
+ const audioIds = audioAssetIds(scene);
+ return statuses(scene)
+ .filter(
+ (status) =>
+ audioIds.has(status.descriptor.id) &&
+ !status.ready,
+ )
+ .map(({ descriptor }) => descriptor.id);
+ }
+
async function decodeAudioAssets(scene, signal, onProgress = () => {}) {
const ids = [...audioAssetIds(scene)];
const entries = [...scene.sources, ...(scene.ambient ?? [])];
@@ -248,6 +259,7 @@ export function createCameraAssetRuntime(onChange = () => {}) {
return imageElement;
},
missingAssetIds,
+ missingAudioAssetIds,
reset,
statuses,
transferListForAudio,
diff --git a/apps/acoustic-camera/index.html b/apps/acoustic-camera/index.html
index fbec281..1b29244 100644
--- a/apps/acoustic-camera/index.html
+++ b/apps/acoustic-camera/index.html
@@ -109,6 +109,48 @@
고급 설정음향 · 범위 · STFT · 식별자
diff --git a/apps/acoustic-camera/main.js b/apps/acoustic-camera/main.js
index 6a59252..2ffdc18 100644
--- a/apps/acoustic-camera/main.js
+++ b/apps/acoustic-camera/main.js
@@ -1,4 +1,14 @@
import { evaluateLocalizationBand, exportBandEvaluation } from "/packages/acoustics-core/evaluation.js";
+import { ceilSampleIndex } from "/packages/acoustics-core/math.js";
+import {
+ DEFAULT_MAXIMUM_ACTIVITY_BIN_DECISIONS,
+ DEFAULT_MAXIMUM_ACTIVITY_FFT_BUTTERFLIES,
+ DEFAULT_MAXIMUM_ACTIVITY_FRAME_COUNT,
+ estimateCfarActivityWorkload,
+} from "/packages/acoustics-core/activity-monitor.js";
+import {
+ estimateDixonSpectralFluxWorkload,
+} from "/packages/acoustics-core/spectral-flux.js";
import { DEFAULT_MAX_COMPENSATION_DB } from "/packages/acoustics-core/frequency-response.js";
import {
localizationAlgorithmDefinition,
@@ -33,6 +43,7 @@ import {
} from "./timeline.js";
import {
alignExperimentToScene,
+ canonicalStringify,
createComparisonSignature,
createHistorySummary,
createResultDocument,
@@ -156,6 +167,26 @@ const elements = {
validationCard: document.querySelector(".validation-card"),
validationTitle: byId("validation-title"),
validationMessage: byId("validation-message"),
+ activityMonitor: byId("activity-monitor"),
+ activityAlgorithm: byId("activity-algorithm"),
+ activityMicrophone: byId("activity-microphone"),
+ activityCfarOptions: byId("activity-cfar-options"),
+ activityPfa: byId("activity-pfa"),
+ activityTraining: byId("activity-training"),
+ activityGuard: byId("activity-guard"),
+ activityRank: byId("activity-rank"),
+ activityFluxOptions: byId("activity-flux-options"),
+ activityPeakPickerEnabled: byId("activity-peak-picker-enabled"),
+ activityDelta: byId("activity-delta"),
+ activityPreMax: byId("activity-pre-max"),
+ activityPreMean: byId("activity-pre-mean"),
+ activityRefractory: byId("activity-refractory"),
+ activityRunButton: byId("activity-run-button"),
+ activityExportButton: byId("activity-export-button"),
+ activityStatus: byId("activity-status"),
+ activityFrame: byId("activity-frame"),
+ activityFrameLabel: byId("activity-frame-label"),
+ activityResultList: byId("activity-result-list"),
};
const layoutSplitPane = createSplitPane({
@@ -178,6 +209,7 @@ const state = {
revision: 0,
validationSequence: 0,
validation: { valid: false, issues: [] },
+ activityValidation: { valid: false, issues: [] },
visibleBandIds: new Set(),
lastRun: null,
live: null,
@@ -189,6 +221,8 @@ const state = {
sceneDirty: false,
experimentDirty: false,
resultDirty: false,
+ activityRun: null,
+ activityDirty: false,
};
const sceneVisualization = createSceneVisualization(elements.sceneCanvas);
@@ -214,6 +248,279 @@ function formatCount(value) {
}).format(value);
}
+function requireActivityNumber(element, label, {
+ integer = false,
+ min = -Infinity,
+ max = Infinity,
+ exclusiveMin = false,
+} = {}) {
+ const value = element.valueAsNumber;
+ if (
+ !Number.isFinite(value) ||
+ (integer && !Number.isInteger(value)) ||
+ (exclusiveMin ? value <= min : value < min) ||
+ value > max
+ ) {
+ const range = exclusiveMin
+ ? min + "보다 크고 " + max + " 이하여야 합니다"
+ : min + " 이상 " + max + " 이하여야 합니다";
+ throw new RangeError(
+ label + "은 " + (integer ? "정수이며 " : "") + range + ".",
+ );
+ }
+ return value;
+}
+
+function readActivityConfiguration() {
+ const algorithm = elements.activityAlgorithm.value;
+ const channelIndex = Number(elements.activityMicrophone.value);
+ if (
+ !Number.isInteger(channelIndex) ||
+ channelIndex < 0 ||
+ channelIndex >= (state.arrayDefinition?.microphones?.length ?? 0)
+ ) {
+ throw new RangeError("활동 검출에 사용할 물리 마이크를 선택하세요.");
+ }
+ if (algorithm === "ca-cfar" || algorithm === "os-cfar") {
+ if (state.experiment?.stft?.window !== "hann") {
+ throw new RangeError("CFAR 입력 STFT window는 hann이어야 합니다.");
+ }
+ const trainingCellsPerSide = requireActivityNumber(
+ elements.activityTraining,
+ "좌우 training cell 수",
+ { integer: true, min: 1, max: 2047 },
+ );
+ const guardCellsPerSide = requireActivityNumber(
+ elements.activityGuard,
+ "좌우 guard cell 수",
+ { integer: true, min: 0, max: 2047 },
+ );
+ const fftSize = state.experiment.stft.fftSize;
+ const nyquistBin = fftSize / 2;
+ if (
+ trainingCellsPerSide + guardCellsPerSide >
+ (nyquistBin - 2) / 2
+ ) {
+ throw new RangeError(
+ "Training+guard 반경이 너무 커서 DC와 Nyquist 사이에 full symmetric CFAR reference window를 만들 수 없습니다.",
+ );
+ }
+ const sceneSampleCount = ceilSampleIndex(
+ state.scene.duration,
+ state.scene.sampleRate,
+ );
+ if (sceneSampleCount < fftSize) {
+ throw new RangeError(
+ "CFAR에는 padding하지 않은 complete frame 하나 이상이 필요합니다. Scene duration 또는 FFT size를 조정하세요.",
+ );
+ }
+ const workload = estimateCfarActivityWorkload({
+ sampleCount: sceneSampleCount,
+ fftSize,
+ hopSize: state.experiment.stft.hopSize,
+ });
+ if (workload.frameCount > DEFAULT_MAXIMUM_ACTIVITY_FRAME_COUNT) {
+ throw new RangeError(
+ "CFAR complete frame 수 " + workload.frameCount +
+ "개가 hard cap " +
+ DEFAULT_MAXIMUM_ACTIVITY_FRAME_COUNT + "개를 넘습니다.",
+ );
+ }
+ if (
+ workload.binDecisionCount >
+ DEFAULT_MAXIMUM_ACTIVITY_BIN_DECISIONS
+ ) {
+ throw new RangeError(
+ "CFAR frame×bin 결정 수 " + workload.binDecisionCount +
+ "개가 hard cap " +
+ DEFAULT_MAXIMUM_ACTIVITY_BIN_DECISIONS + "개를 넘습니다.",
+ );
+ }
+ if (
+ workload.fftButterflyCount >
+ DEFAULT_MAXIMUM_ACTIVITY_FFT_BUTTERFLIES
+ ) {
+ throw new RangeError(
+ "CFAR FFT butterfly 수 " + workload.fftButterflyCount +
+ "개가 hard cap " +
+ DEFAULT_MAXIMUM_ACTIVITY_FFT_BUTTERFLIES + "개를 넘습니다.",
+ );
+ }
+ const nominalFalseAlarmProbability = requireActivityNumber(
+ elements.activityPfa,
+ "명목 Pfa",
+ { min: 0, max: 1, exclusiveMin: true },
+ );
+ if (nominalFalseAlarmProbability >= 1) {
+ throw new RangeError("명목 Pfa는 0보다 크고 1보다 작아야 합니다.");
+ }
+ const configuration = {
+ algorithm,
+ channelIndex,
+ trainingCellsPerSide,
+ guardCellsPerSide,
+ nominalFalseAlarmProbability,
+ };
+ if (algorithm === "os-cfar") {
+ configuration.rank = requireActivityNumber(
+ elements.activityRank,
+ "OS rank",
+ {
+ integer: true,
+ min: 1,
+ max: 2 * trainingCellsPerSide,
+ },
+ );
+ }
+ return configuration;
+ }
+ if (algorithm !== "dixon-spectral-flux") {
+ throw new RangeError("알 수 없는 활동 검출 알고리즘입니다.");
+ }
+ const configuration = {
+ algorithm,
+ channelIndex,
+ };
+ estimateDixonSpectralFluxWorkload({
+ sampleCount: ceilSampleIndex(
+ state.scene.duration,
+ state.scene.sampleRate,
+ ),
+ fftSize: state.experiment.stft.fftSize,
+ hopSize: state.experiment.stft.hopSize,
+ });
+ if (elements.activityPeakPickerEnabled.checked) {
+ configuration.peakPicker = {
+ delta: requireActivityNumber(elements.activityDelta, "Spectral flux δ", {
+ min: 0,
+ max: Number.MAX_VALUE,
+ }),
+ preMaxFrames: requireActivityNumber(
+ elements.activityPreMax,
+ "과거 local-max frame 수",
+ { integer: true, min: 0, max: 4095 },
+ ),
+ preMeanFrames: requireActivityNumber(
+ elements.activityPreMean,
+ "과거 mean frame 수",
+ { integer: true, min: 0, max: 4095 },
+ ),
+ refractoryFrames: requireActivityNumber(
+ elements.activityRefractory,
+ "refractory frame 수",
+ { integer: true, min: 0, max: 4095 },
+ ),
+ };
+ }
+ return configuration;
+}
+
+function activityInputSignature(snapshot, activityConfig) {
+ const selectedMicrophone =
+ snapshot.arrayDefinition.microphones[activityConfig.channelIndex];
+ const audioAssetIds = new Set();
+ for (const entry of [
+ ...snapshot.scene.sources,
+ ...(snapshot.scene.ambient ?? []),
+ ]) {
+ if (entry.signal?.type === "asset") {
+ audioAssetIds.add(entry.signal.assetId);
+ }
+ }
+ const {
+ image: _backgroundImage,
+ assets: sceneAssets = [],
+ ...acousticScene
+ } = snapshot.scene;
+ return canonicalStringify({
+ selectedPhysicalMicrophone: {
+ arrayUnits: snapshot.arrayDefinition.units,
+ coordinateSystem: snapshot.arrayDefinition.coordinateSystem,
+ index: activityConfig.channelIndex,
+ microphone: selectedMicrophone,
+ },
+ scene: {
+ ...acousticScene,
+ assets: sceneAssets.filter((asset) => audioAssetIds.has(asset.id)),
+ },
+ analysis: {
+ stft: snapshot.experiment.stft,
+ minimumDistance: snapshot.experiment.beamforming.minimumDistance,
+ activityConfig,
+ },
+ });
+}
+
+async function sha256Text(value) {
+ const digest = await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(value),
+ );
+ return [...new Uint8Array(digest)]
+ .map((byte) => byte.toString(16).padStart(2, "0"))
+ .join("");
+}
+
+function currentActivitySignature() {
+ if (!state.arrayDefinition || !state.scene || !state.experiment) return "";
+ try {
+ return activityInputSignature(
+ {
+ arrayDefinition: state.arrayDefinition,
+ scene: state.scene,
+ experiment: state.experiment,
+ },
+ readActivityConfiguration(),
+ );
+ } catch {
+ return "";
+ }
+}
+
+function activityRunIsStale() {
+ return Boolean(
+ state.activityRun &&
+ state.activityRun.inputSignature !== currentActivitySignature()
+ );
+}
+
+function plainActivityValue(value) {
+ if (typeof value === "number" && !Number.isFinite(value)) {
+ if (value === Number.POSITIVE_INFINITY) return "Infinity";
+ if (value === Number.NEGATIVE_INFINITY) return "-Infinity";
+ return "NaN";
+ }
+ if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
+ return Array.from(value, plainActivityValue);
+ }
+ if (Array.isArray(value)) return value.map(plainActivityValue);
+ if (value && typeof value === "object") {
+ return Object.fromEntries(
+ Object.entries(value).map(([key, item]) => [
+ key,
+ plainActivityValue(item),
+ ]),
+ );
+ }
+ return value;
+}
+
+function createActivityReportDocument(activityRun) {
+ const { snapshot, workerReport, inputHash } = activityRun;
+ return {
+ schemaVersion: "1.0.0",
+ kind: "micarrsim.activity-report",
+ createdAt: activityRun.createdAt,
+ arrayId: snapshot.arrayDefinition.id,
+ sceneId: snapshot.scene.id,
+ experimentId: snapshot.experiment.id,
+ inputHash,
+ provenanceOnlyIds: true,
+ detectorSceneTruthInput: false,
+ report: plainActivityValue(workerReport),
+ };
+}
+
function formatIssue(issue) {
return `${issue.path}: ${issue.message}`;
}
@@ -246,6 +553,7 @@ function unsavedWorkLabels() {
if (state.sceneDirty) labels.push("Scene");
if (state.experimentDirty) labels.push("Experiment");
if (state.resultDirty) labels.push("Result");
+ if (state.activityDirty) labels.push("Activity report");
if (state.history.some((item) => !item.comparisonSaved)) labels.push("비교 기록");
return labels;
}
@@ -352,6 +660,7 @@ function setConfigurationLocked(locked) {
elements.exportResultButton.disabled =
locked || !state.lastRun?.resultDocument;
renderLiveControls();
+ renderActivityControls();
}
function setEditableControlsDisabled(disabled) {
@@ -370,6 +679,7 @@ function beginInputOperation() {
input.disabled = true;
});
renderLiveControls();
+ renderActivityControls();
}
function endInputOperation() {
@@ -385,6 +695,7 @@ function endInputOperation() {
});
}
renderLiveControls();
+ renderActivityControls();
scheduleValidation();
}
@@ -410,6 +721,7 @@ function markChanged(options = {}) {
renderSceneVisualization();
renderResult();
renderHistory();
+ renderActivityResult();
syncUnsavedState();
scheduleValidation();
}
@@ -485,6 +797,7 @@ function syncControls() {
experiment.scan.columns === experiment.scan.rows && [50, 100, 200].includes(experiment.scan.rows)
? String(experiment.scan.rows)
: "custom";
+ renderActivityResult();
}
function renderAcousticSummary() {
@@ -670,6 +983,418 @@ function renderBandEditors() {
elements.addBandButton.disabled = state.experiment.frequencyBands.length >= 8 || Boolean(state.active);
}
+function syncActivityMicrophones() {
+ const microphones = state.arrayDefinition?.microphones ?? [];
+ const previous = elements.activityMicrophone.value;
+ const optionKey = canonicalStringify(
+ microphones.map(({ id, position }) => ({ id, position })),
+ );
+ if (elements.activityMicrophone.dataset.optionKey !== optionKey) {
+ elements.activityMicrophone.replaceChildren();
+ microphones.forEach((microphone, index) => {
+ const option = document.createElement("option");
+ option.value = String(index);
+ option.textContent =
+ (index + 1) + " · " + microphone.id +
+ " · [" + microphone.position.map((value) => formatNumber(value, 3)).join(", ") + "] m";
+ elements.activityMicrophone.append(option);
+ });
+ elements.activityMicrophone.dataset.optionKey = optionKey;
+ elements.activityMicrophone.value =
+ microphones[Number(previous)] ? previous : "0";
+ }
+}
+
+function renderActivityControls() {
+ syncActivityMicrophones();
+ const algorithm = elements.activityAlgorithm.value;
+ const cfar = algorithm === "ca-cfar" || algorithm === "os-cfar";
+ const os = algorithm === "os-cfar";
+ const flux = algorithm === "dixon-spectral-flux";
+ const peakPicker = flux && elements.activityPeakPickerEnabled.checked;
+ const locked = Boolean(state.active) || state.pendingInputOperations > 0;
+ elements.activityCfarOptions.hidden = !cfar;
+ elements.activityFluxOptions.hidden = !flux;
+ const cfarNote = elements.activityMonitor.querySelector(
+ ".activity-monitor__note--cfar",
+ );
+ const fluxNote = elements.activityMonitor.querySelector(
+ ".activity-monitor__note--flux",
+ );
+ if (cfarNote) cfarNote.hidden = !cfar;
+ if (fluxNote) fluxNote.hidden = !flux;
+ elements.activityRank.disabled = locked || !os;
+ elements.activityDelta.disabled = locked || !peakPicker;
+ elements.activityPreMax.disabled = locked || !peakPicker;
+ elements.activityPreMean.disabled = locked || !peakPicker;
+ elements.activityRefractory.disabled = locked || !peakPicker;
+ elements.activityRunButton.disabled =
+ locked || !state.activityValidation.valid || !state.arrayDefinition;
+ elements.activityExportButton.disabled =
+ locked || !state.activityRun;
+ elements.activityFrame.disabled =
+ locked || !state.activityRun;
+ const training = Number(elements.activityTraining.value);
+ if (Number.isInteger(training) && training > 0) {
+ elements.activityRank.max = String(2 * training);
+ }
+}
+
+function appendActivityMessage(text, className = "activity-message") {
+ const message = document.createElement("p");
+ message.className = className;
+ message.textContent = text;
+ elements.activityResultList.append(message);
+ return message;
+}
+
+function formatActivityStatistic(value) {
+ if (value === null || value === undefined) return "—";
+ if (!Number.isFinite(value)) return String(value);
+ if (value === 0) return "0";
+ if (Math.abs(value) >= 1e4 || Math.abs(value) < 1e-3) {
+ return value.toExponential(4);
+ }
+ return value.toPrecision(6);
+}
+
+function setActivityFrameBounds(frameCount) {
+ const maximum = Math.max(0, frameCount - 1);
+ elements.activityFrame.max = String(maximum);
+ let frameIndex = Number(elements.activityFrame.value);
+ if (!Number.isInteger(frameIndex) || frameIndex < 0 || frameIndex > maximum) {
+ frameIndex = 0;
+ elements.activityFrame.value = "0";
+ }
+ return frameIndex;
+}
+
+function renderCfarActivityResult(workerReport) {
+ const analysis = workerReport.analysis;
+ const frameIndex = setActivityFrameBounds(analysis.frames.length);
+ const frame = analysis.frames[frameIndex];
+ const model = analysis.detectorModel;
+ const stale = activityRunIsStale();
+ elements.activityFrameLabel.textContent =
+ "frame " + (frameIndex + 1) + "/" + analysis.frames.length +
+ " · start " +
+ formatNumber(frame.frameStartSample / workerReport.input.sampleRate, 4) +
+ " s · available " +
+ formatNumber(
+ frame.frameAvailableAfterSample / workerReport.input.sampleRate,
+ 4,
+ ) +
+ " s";
+ appendActivityMessage(
+ (workerReport.algorithm === "os-cfar" ? "OS-CFAR" : "CA-CFAR") +
+ " · " + workerReport.channel.id +
+ " · complete frame " + analysis.summary.frameCount + "개" +
+ " · detected bin 결정 " + analysis.summary.detectedBinCount + "개" +
+ (stale ? " · 현재 입력/검출 설정과 다름" : ""),
+ stale ? "activity-message is-stale" : "activity-message",
+ );
+ appendActivityMessage(
+ "Pfa " + formatActivityStatistic(
+ model.parameters.nominalPfaPerTestedCutUnderIidExponentialModel,
+ ) +
+ "는 IID exponential 모델의 tested CUT 하나당 명목값입니다. " +
+ "실측 확률·confidence·frame 전체 오경보율이 아닙니다.",
+ "activity-caveat",
+ );
+ appendActivityMessage(
+ "선택 frame · tested " + frame.testedBinCount +
+ " · untested " + frame.untestedBinCount +
+ " · detected " + frame.detectedBinCount +
+ ". 아래에는 이 frame의 모든 detected bin을 병합 없이 표시합니다.",
+ "activity-frame-summary",
+ );
+ const testedBins = frame.bins.filter((bin) => bin.status !== "untested");
+ appendActivityMessage(
+ testedBins.length > 0
+ ? "CFAR tested 범위 " +
+ formatNumber(testedBins[0].adcCenterFrequencyHz, 2) + "–" +
+ formatNumber(testedBins.at(-1).adcCenterFrequencyHz, 2) +
+ " Hz. DC·Nyquist와 좌우 full reference window가 부족한 edge bin은 줄이거나 wrap하지 않고 untested입니다."
+ : "이 설정에서는 full symmetric reference window를 만들 수 없어 CFAR tested bin이 없습니다. DC·Nyquist를 포함한 모든 bin은 raw 상태로만 보존됩니다.",
+ "activity-caveat",
+ );
+ const detected = frame.bins.filter((bin) => bin.status === "detected");
+ if (detected.length === 0) {
+ appendActivityMessage(
+ "이 frame에는 threshold를 엄격히 초과한 ADC bin이 없습니다. raw/untested 항목은 Activity JSON에 모두 남아 있습니다.",
+ "activity-empty",
+ );
+ return;
+ }
+ for (const bin of detected) {
+ const row = document.createElement("div");
+ row.className = "activity-result-row";
+ const heading = document.createElement("strong");
+ heading.textContent =
+ "ADC bin " + bin.adcBin + " · " +
+ formatNumber(bin.adcCenterFrequencyHz, 2) + " Hz";
+ const raw = document.createElement("span");
+ raw.textContent =
+ "CUT " + formatActivityStatistic(bin.cutPower) +
+ " · ref " + formatActivityStatistic(bin.referencePower) +
+ " · threshold " + formatActivityStatistic(bin.thresholdPower) +
+ " · ratio " + (
+ bin.thresholdRatio === null
+ ? bin.thresholdRatioStatus
+ : formatActivityStatistic(bin.thresholdRatio)
+ );
+ const hypothesis = document.createElement("label");
+ hypothesis.className = "activity-hypothesis";
+ const label = document.createElement("span");
+ label.textContent = "아날로그 center 가설 (Hz)";
+ const input = document.createElement("input");
+ input.type = "number";
+ input.min = "0";
+ input.max = String(MAX_PRECISE_ANALOG_FREQUENCY_HZ);
+ input.step = String(model.spectrum.binWidthHz);
+ input.value = String(bin.adcCenterFrequencyHz);
+ input.dataset.activityAnalogHypothesis = "true";
+ input.dataset.activityFrameIndex = String(frameIndex);
+ input.dataset.activityBin = String(bin.adcBin);
+ const add = document.createElement("button");
+ add.type = "button";
+ add.className = "mini-button";
+ add.dataset.addActivityBand = "true";
+ add.dataset.activityFrameIndex = String(frameIndex);
+ add.dataset.activityBin = String(bin.adcBin);
+ add.textContent = "이 가설을 대역으로 추가";
+ add.disabled = stale;
+ add.title = stale
+ ? "현재 입력 또는 검출 설정과 다른 결과입니다. 다시 검사한 뒤 추가하세요."
+ : "이 ADC 관측에 대응하는 명시적 analog carrier 가설을 추가합니다.";
+ hypothesis.append(label, input, add);
+ row.append(heading, raw, hypothesis);
+ elements.activityResultList.append(row);
+ }
+}
+
+function renderFluxActivityResult(workerReport) {
+ const analysis = workerReport.analysis;
+ if (analysis.frameCount === 0) {
+ setActivityFrameBounds(0);
+ elements.activityFrameLabel.textContent = "complete frame 0개";
+ appendActivityMessage(
+ "현재 sample 길이가 FFT size보다 짧아 Dixon spectral flux를 계산할 완전한 frame이 없습니다. padding한 값을 만들지 않았습니다.",
+ "activity-empty",
+ );
+ return;
+ }
+ const frameIndex = setActivityFrameBounds(analysis.frameCount);
+ const stale = activityRunIsStale();
+ const frameTime = analysis.frameStartSamples[frameIndex] / analysis.sampleRate;
+ elements.activityFrameLabel.textContent =
+ "frame " + (frameIndex + 1) + "/" + analysis.frameCount +
+ " · start " + formatNumber(frameTime, 4) +
+ " s · available " +
+ formatNumber(
+ analysis.frameAvailableAfterSamples[frameIndex] / analysis.sampleRate,
+ 4,
+ ) +
+ " s";
+ appendActivityMessage(
+ "Dixon spectral flux · " + workerReport.channel.id +
+ " · raw onset novelty frame " + analysis.frameCount + "개" +
+ (stale ? " · 현재 입력/검출 설정과 다름" : ""),
+ stale ? "activity-message is-stale" : "activity-message",
+ );
+ appendActivityMessage(
+ "선택 frame raw SF = " + formatActivityStatistic(analysis.rawOdf[frameIndex]) +
+ " · availability " +
+ formatNumber(
+ analysis.frameAvailableAfterSamples[frameIndex] / analysis.sampleRate,
+ 4,
+ ) +
+ " s. 이 값은 probability·confidence·지속 activity가 아닙니다.",
+ "activity-frame-summary",
+ );
+ if (!workerReport.peakPicking) {
+ appendActivityMessage(
+ "Peak picker를 적용하지 않아 임의 event threshold를 만들지 않았습니다. raw ODF와 모든 per-bin 양의 차이는 Activity JSON에 보존됩니다.",
+ "activity-caveat",
+ );
+ return;
+ }
+ const picking = workerReport.peakPicking;
+ appendActivityMessage(
+ "Böck–Krebs–Schedl causal picker · δ " +
+ formatActivityStatistic(picking.configuration.delta) +
+ " · event " + picking.peaks.length +
+ "개. δ는 사용자가 보정한 operating offset입니다.",
+ "activity-caveat",
+ );
+ const selectedIsPeak = Boolean(picking.peakMask[frameIndex]);
+ appendActivityMessage(
+ "선택 frame threshold " +
+ formatActivityStatistic(picking.adaptiveThresholds[frameIndex]) +
+ " · " + (selectedIsPeak ? "onset event" : "event 아님"),
+ selectedIsPeak ? "activity-event-selected" : "activity-frame-summary",
+ );
+ if (picking.peaks.length === 0) {
+ appendActivityMessage("설정한 causal threshold를 통과한 event가 없습니다.", "activity-empty");
+ return;
+ }
+ for (const peak of picking.peaks) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "activity-event-button";
+ button.dataset.activityViewFrame = String(peak.frameIndex);
+ button.textContent =
+ "start " + formatNumber(
+ analysis.frameStartSamples[peak.frameIndex] / analysis.sampleRate,
+ 4,
+ ) +
+ " s · available " +
+ formatNumber(
+ analysis.frameAvailableAfterSamples[peak.frameIndex] /
+ analysis.sampleRate,
+ 4,
+ ) +
+ " s · SF " + formatActivityStatistic(peak.rawOdf) +
+ " · threshold " + formatActivityStatistic(peak.adaptiveThreshold);
+ elements.activityResultList.append(button);
+ }
+}
+
+function renderActivityResult() {
+ elements.activityResultList.replaceChildren();
+ const activityRun = state.activityRun;
+ if (!activityRun) {
+ elements.activityFrame.max = "0";
+ elements.activityFrame.value = "0";
+ elements.activityFrameLabel.textContent = "검출 결과 없음";
+ elements.activityStatus.textContent =
+ "위치추정과 별도로 ADC 0–Fs/2 spectrum을 만듭니다. Scene의 음원 정답은 검출기에 전달하지 않습니다.";
+ document.documentElement.dataset.activityState = "idle";
+ document.documentElement.dataset.activityDetectedCount = "0";
+ renderActivityControls();
+ return;
+ }
+ const workerReport = activityRun.workerReport;
+ if (
+ workerReport.algorithm === "ca-cfar" ||
+ workerReport.algorithm === "os-cfar"
+ ) {
+ renderCfarActivityResult(workerReport);
+ document.documentElement.dataset.activityDetectedCount =
+ String(workerReport.analysis.summary.detectedBinCount);
+ } else {
+ renderFluxActivityResult(workerReport);
+ document.documentElement.dataset.activityDetectedCount =
+ String(workerReport.peakPicking?.peaks?.length ?? 0);
+ }
+ if (workerReport.signalStats.preprocessing?.futureDependentNormalization) {
+ appendActivityMessage(
+ "Scene의 peak normalization은 선택한 물리 마이크 한 채널의 전체 render peak로 먼저 scale합니다. detector는 그 뒤에는 causal이지만 raw spectral flux와 δ event를 포함한 end-to-end 전처리는 미래 sample에 의존합니다.",
+ "activity-caveat",
+ );
+ }
+ const stale = activityRunIsStale();
+ elements.activityStatus.textContent =
+ (stale ? "과거 설정 결과 · " : "") +
+ workerReport.algorithm + " · " +
+ formatNumber(workerReport.timingMs.total, 0) + " ms · hash " +
+ activityRun.inputHash.slice(0, 10);
+ document.documentElement.dataset.activityState = stale ? "stale" : "done";
+ document.documentElement.dataset.activityAlgorithm = workerReport.algorithm;
+ renderActivityControls();
+}
+
+function addActivityBand(button) {
+ const activityRun = state.activityRun;
+ const workerReport = activityRun?.workerReport;
+ if (
+ !workerReport ||
+ (workerReport.algorithm !== "ca-cfar" &&
+ workerReport.algorithm !== "os-cfar")
+ ) return;
+ if (activityRunIsStale()) {
+ setTransientError(
+ new Error("현재 입력 또는 검출 설정과 다른 Activity 결과입니다. 다시 검사한 뒤 대역을 추가하세요."),
+ "활동 후보 추가 실패",
+ );
+ return;
+ }
+ if (state.experiment.frequencyBands.length >= 8) {
+ setTransientError(
+ new RangeError("Experiment band는 최대 8개입니다. 기존 band를 정리한 뒤 추가하세요."),
+ "활동 후보 추가 실패",
+ );
+ return;
+ }
+ const frameIndex = Number(button.dataset.activityFrameIndex);
+ const adcBin = Number(button.dataset.activityBin);
+ const input = elements.activityResultList.querySelector(
+ 'input[data-activity-analog-hypothesis="true"]' +
+ '[data-activity-frame-index="' + frameIndex + '"]' +
+ '[data-activity-bin="' + adcBin + '"]',
+ );
+ const analogCenterHz = input?.valueAsNumber;
+ const sampleRate = workerReport.input.sampleRate;
+ const fftSize = workerReport.input.fftSize;
+ const binWidthHz = sampleRate / fftSize;
+ if (
+ !Number.isFinite(analogCenterHz) ||
+ analogCenterHz <= 0 ||
+ analogCenterHz > MAX_PRECISE_ANALOG_FREQUENCY_HZ
+ ) {
+ setTransientError(
+ new RangeError("아날로그 center 가설은 0보다 크고 1 MHz 이하여야 합니다."),
+ "활동 후보 추가 실패",
+ );
+ return;
+ }
+ const analogGridBin = Math.round(analogCenterHz / binWidthHz);
+ const gridErrorHz = Math.abs(analogCenterHz - analogGridBin * binWidthHz);
+ const observation = describeFrequencyAliasing(analogCenterHz, sampleRate);
+ const observedBin = Math.round(observation.adcFrequencyHz / binWidthHz);
+ if (
+ gridErrorHz > 1e-8 * Math.max(1, binWidthHz) ||
+ observedBin !== adcBin
+ ) {
+ setTransientError(
+ new RangeError(
+ "가설은 FFT grid center에 놓이고 선택한 ADC bin으로 접혀야 합니다. " +
+ "bin 간격은 " + formatNumber(binWidthHz, 6) + " Hz입니다.",
+ ),
+ "활동 후보 추가 실패",
+ );
+ return;
+ }
+ const halfCellHz = binWidthHz / 2;
+ const minHz = analogCenterHz - halfCellHz;
+ const maxHz = analogCenterHz + halfCellHz;
+ if (minHz < 0 || maxHz > MAX_PRECISE_ANALOG_FREQUENCY_HZ) {
+ setTransientError(
+ new RangeError("이 FFT cell은 검증된 0–1 MHz 아날로그 band 범위를 벗어납니다."),
+ "활동 후보 추가 실패",
+ );
+ return;
+ }
+ const usedIds = new Set(state.experiment.frequencyBands.map((band) => band.id));
+ let suffix = 1;
+ while (usedIds.has("activity-" + suffix)) suffix += 1;
+ const band = {
+ id: "activity-" + suffix,
+ minHz,
+ maxHz,
+ color: BAND_COLORS[state.experiment.frequencyBands.length % BAND_COLORS.length],
+ };
+ state.experiment.frequencyBands.push(band);
+ state.visibleBandIds.add(band.id);
+ markChanged({ renderBands: true });
+ setRunStatus(
+ band.id + " 추가 · analog " + formatNumber(analogCenterHz, 3) +
+ " Hz 가설 → ADC bin " + adcBin +
+ ". 다른 alias order는 별도 가설로 추가하세요.",
+ );
+ renderActivityResult();
+}
+
function renderLegend() {
elements.bandLegend.replaceChildren();
elements.samplingWarning.hidden = true;
@@ -1261,7 +1986,14 @@ async function validateCurrent() {
issues: [{ path: "$", message: "문서를 불러오는 중입니다." }],
warnings: [],
};
- if (sequence === state.validationSequence) applyValidation(validation);
+ if (sequence === state.validationSequence) {
+ state.activityValidation = {
+ valid: false,
+ issues: validation.issues,
+ };
+ applyValidation(validation);
+ }
+ validation.activity = state.activityValidation;
return validation;
}
const revision = state.revision;
@@ -1293,13 +2025,79 @@ async function validateCurrent() {
if (state.pendingInputOperations > 0) {
issues.push({ path: "$", message: "선택한 파일을 확인하고 있습니다. 잠시 기다리세요." });
}
+ const activityExperimentIssues = [];
+ const supportedActivityFftSizes = [128, 256, 512, 1024, 2048, 4096];
+ if (!snapshot.experiment.stft || typeof snapshot.experiment.stft !== "object") {
+ activityExperimentIssues.push({
+ path: "experiment.stft",
+ message: "Activity 분석용 STFT 설정이 필요합니다.",
+ });
+ } else {
+ const { fftSize, hopSize } = snapshot.experiment.stft;
+ if (!supportedActivityFftSizes.includes(fftSize)) {
+ activityExperimentIssues.push({
+ path: "experiment.stft.fftSize",
+ message: "Activity 분석은 128–4096의 지원되는 2의 거듭제곱 FFT size가 필요합니다.",
+ });
+ }
+ if (
+ !Number.isInteger(hopSize) ||
+ hopSize < 1 ||
+ !Number.isInteger(fftSize) ||
+ hopSize > fftSize
+ ) {
+ activityExperimentIssues.push({
+ path: "experiment.stft.hopSize",
+ message: "Activity hop size는 1 이상 FFT size 이하의 정수여야 합니다.",
+ });
+ }
+ }
+ const minimumDistance = snapshot.experiment.beamforming?.minimumDistance;
+ if (
+ !Number.isFinite(minimumDistance) ||
+ minimumDistance <= 0 ||
+ minimumDistance > 10
+ ) {
+ activityExperimentIssues.push({
+ path: "experiment.beamforming.minimumDistance",
+ message: "물리 신호 합성용 최소 거리는 0보다 크고 10 m 이하여야 합니다.",
+ });
+ }
+ const activityIssues = [
+ ...arrayIssues.map((issue) => ({ ...issue, path: `array${issue.path}` })),
+ ...sceneIssues.map((issue) => ({ ...issue, path: `scene${issue.path}` })),
+ ...activityExperimentIssues,
+ ];
+ const missingAudioAssets =
+ assetRuntime.missingAudioAssetIds(snapshot.scene);
+ if (missingAudioAssets.length > 0) {
+ activityIssues.push({
+ path: "scene.assets",
+ message: `오디오 원본 파일을 연결하세요: ${missingAudioAssets.join(", ")}`,
+ });
+ }
+ if (state.pendingInputOperations > 0) {
+ activityIssues.push({
+ path: "$",
+ message: "선택한 파일을 확인하고 있습니다. 잠시 기다리세요.",
+ });
+ }
+ const activityValidation = {
+ valid: activityIssues.length === 0,
+ issues: activityIssues,
+ revision,
+ };
const validation = {
valid: issues.length === 0,
issues,
warnings: validationWarnings(),
revision,
};
- if (sequence === state.validationSequence) applyValidation(validation);
+ if (sequence === state.validationSequence) {
+ state.activityValidation = activityValidation;
+ applyValidation(validation);
+ }
+ validation.activity = activityValidation;
return validation;
}
@@ -1334,6 +2132,7 @@ function applyValidation(validation) {
}
}
renderLiveControls();
+ renderActivityControls();
}
function scheduleValidation() {
@@ -1377,6 +2176,9 @@ function runWorker(activity, payload, transfer, resultOptions = {}) {
document.documentElement.dataset.runStage = message.stage ?? "worker";
setRunStatus(progressMessage(message));
if (activity.kind === "live") elements.liveStatus.textContent = progressMessage(message);
+ if (activity.kind === "activity") {
+ elements.activityStatus.textContent = progressMessage(message);
+ }
} else if (message.type === "fallback") {
activity.fallback = { code: message.code, message: message.message };
setRunStatus(`WebGPU 사용 불가 · CPU로 계속합니다 (${message.code})`);
@@ -1403,9 +2205,154 @@ function runWorker(activity, payload, transfer, resultOptions = {}) {
});
}
+async function runActivityDetection() {
+ if (state.active) return;
+ let activityConfig;
+ try {
+ activityConfig = readActivityConfiguration();
+ } catch (error) {
+ setTransientError(error, "활동 검출 설정 오류");
+ elements.activityMonitor.open = true;
+ return;
+ }
+ const validation = await validateCurrent();
+ if (state.active) return;
+ try {
+ activityConfig = readActivityConfiguration();
+ } catch (error) {
+ setTransientError(error, "활동 검출 설정 오류");
+ elements.activityMonitor.open = true;
+ return;
+ }
+ if (!validation.activity?.valid) {
+ setTransientError(
+ new Error(
+ (validation.activity?.issues ?? [])
+ .slice(0, 2)
+ .map(formatIssue)
+ .join(" · "),
+ ),
+ "활동 검출 전 입력 검증 실패",
+ );
+ return;
+ }
+ if (
+ validation.activity.revision !== state.revision ||
+ state.pendingInputOperations > 0
+ ) {
+ setRunStatus("입력 파일 또는 설정 검증이 끝난 뒤 전 대역 검사를 다시 실행하세요.");
+ await validateCurrent();
+ return;
+ }
+ if (
+ state.activityDirty &&
+ !window.confirm("직전 Activity report를 아직 저장하지 않았습니다. 새 검사로 교체할까요?")
+ ) {
+ setRunStatus("직전 Activity JSON을 저장한 뒤 새 검사를 실행할 수 있습니다.");
+ return;
+ }
+ const snapshot = {
+ arrayDefinition: structuredClone(state.arrayDefinition),
+ scene: structuredClone(state.scene),
+ experiment: structuredClone(state.experiment),
+ };
+ const inputSignature = activityInputSignature(snapshot, activityConfig);
+ const activity = {
+ jobId: "activity-" + Date.now() + "-" + ++jobSequence,
+ kind: "activity",
+ revision: state.revision,
+ abortController: new AbortController(),
+ worker: null,
+ reject: null,
+ canceled: false,
+ finished: false,
+ };
+ state.active = activity;
+ document.documentElement.dataset.activityState = "running";
+ document.documentElement.dataset.runStage = "activity-assets";
+ setConfigurationLocked(true);
+ elements.activityMonitor.open = true;
+ elements.activityStatus.textContent =
+ "입력을 고정하고 선택한 물리 마이크의 전 대역 검사를 준비하고 있습니다…";
+ setRunStatus("전 대역 활동 검사를 준비하고 있습니다…");
+ try {
+ const inputHashPromise = sha256Text(inputSignature);
+ const assetBuffers = await assetRuntime.decodeAudioAssets(
+ snapshot.scene,
+ activity.abortController.signal,
+ ({ completed, total, fileName }) => {
+ if (state.active !== activity) return;
+ const message = total > 0
+ ? "활동 검사 audio decode · " + completed + "/" + total +
+ (fileName ? " · " + fileName : "")
+ : "활동 검사 synthetic signal 준비 완료";
+ setRunStatus(message);
+ elements.activityStatus.textContent = message;
+ },
+ );
+ if (activity.canceled || state.active !== activity) throw createAbortError();
+ document.documentElement.dataset.runStage = "activity-worker";
+ const workerReport = await runWorker(
+ activity,
+ {
+ type: "detect-activity",
+ jobId: activity.jobId,
+ scene: snapshot.scene,
+ arrayDefinition: snapshot.arrayDefinition,
+ experiment: snapshot.experiment,
+ assetBuffers,
+ activityConfig,
+ },
+ assetRuntime.transferListForAudio(assetBuffers),
+ { type: "activity-result", key: "activityReport" },
+ );
+ if (activity.canceled || state.active !== activity) throw createAbortError();
+ const inputHash = await inputHashPromise;
+ state.activityRun = {
+ snapshot,
+ activityConfig,
+ inputSignature,
+ inputHash,
+ workerReport,
+ createdAt: new Date().toISOString(),
+ };
+ state.activityDirty = true;
+ elements.activityFrame.value = "0";
+ document.documentElement.dataset.activityState = "done";
+ syncUnsavedState();
+ renderActivityResult();
+ setRunStatus(
+ "전 대역 검사 완료 · " + workerReport.algorithm +
+ " · " + formatNumber(workerReport.timingMs.total, 0) +
+ " ms · hash " + inputHash.slice(0, 10),
+ );
+ } catch (error) {
+ if (error?.name === "AbortError" || activity.canceled) {
+ document.documentElement.dataset.activityState = "canceled";
+ elements.activityStatus.textContent =
+ "전 대역 검사를 취소했습니다. 직전 Activity report는 유지됩니다.";
+ setRunStatus("전 대역 검사를 취소했습니다.");
+ } else {
+ document.documentElement.dataset.activityState = "error";
+ elements.activityStatus.textContent =
+ "전 대역 검사 실패 · " +
+ (error instanceof Error ? error.message : String(error));
+ setTransientError(error, "전 대역 검사 실패");
+ }
+ } finally {
+ activity.worker?.terminate();
+ if (state.active === activity) state.active = null;
+ setConfigurationLocked(false);
+ renderBandEditors();
+ renderActivityControls();
+ await validateCurrent();
+ }
+}
+
async function runSimulation() {
if (state.active) return;
const validation = await validateCurrent();
+ if (state.active) return;
if (!validation.valid) {
setTransientError(
new Error(validation.issues.slice(0, 2).map(formatIssue).join(" · ")),
@@ -1690,6 +2637,7 @@ function evaluateLiveFrames(snapshot, plan, frames) {
async function prepareLiveSimulation() {
if (state.active) return;
const validation = await validateCurrent();
+ if (state.active) return;
if (!validation.valid) {
state.liveError = validation.issues.slice(0, 2).map(formatIssue).join(" · ");
renderLiveControls();
@@ -2282,6 +3230,53 @@ function installBandListeners() {
});
}
+function installActivityListeners() {
+ elements.activityRunButton.addEventListener("click", runActivityDetection);
+ elements.activityExportButton.addEventListener("click", () => {
+ if (!state.activityRun) return;
+ const documentValue = createActivityReportDocument(state.activityRun);
+ downloadJson(
+ documentValue,
+ safeFileStem(state.activityRun.snapshot.scene.id) + ".activity.json",
+ );
+ state.activityDirty = false;
+ syncUnsavedState();
+ setRunStatus("모든 raw frame/bin 통계를 포함한 Activity JSON을 저장했습니다.");
+ });
+ elements.activityFrame.addEventListener("input", renderActivityResult);
+ elements.activityResultList.addEventListener("click", (event) => {
+ const addButton = event.target.closest("button[data-add-activity-band]");
+ if (addButton) {
+ addActivityBand(addButton);
+ return;
+ }
+ const frameButton = event.target.closest("button[data-activity-view-frame]");
+ if (frameButton) {
+ elements.activityFrame.value = frameButton.dataset.activityViewFrame;
+ renderActivityResult();
+ }
+ });
+ const configurationControls = [
+ elements.activityAlgorithm,
+ elements.activityMicrophone,
+ elements.activityPfa,
+ elements.activityTraining,
+ elements.activityGuard,
+ elements.activityRank,
+ elements.activityPeakPickerEnabled,
+ elements.activityDelta,
+ elements.activityPreMax,
+ elements.activityPreMean,
+ elements.activityRefractory,
+ ];
+ for (const control of configurationControls) {
+ control.addEventListener("change", () => {
+ renderActivityControls();
+ renderActivityResult();
+ });
+ }
+}
+
function installActionListeners() {
elements.runButton.addEventListener("click", runSimulation);
elements.cancelButton.addEventListener("click", cancelSimulation);
@@ -2419,10 +3414,12 @@ function installActionListeners() {
async function initialize() {
installDocumentInputListeners();
installBandListeners();
+ installActivityListeners();
installActionListeners();
setEditableControlsDisabled(true);
document.documentElement.dataset.runState = "idle";
renderLiveControls();
+ renderActivityResult();
try {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson(DEFAULT_DOCUMENT_URLS.arrayDefinition),
@@ -2446,6 +3443,7 @@ async function initialize() {
syncUnsavedState();
await validateCurrent();
setEditableControlsDisabled(false);
+ renderActivityControls();
renderBandEditors();
elements.runButton.disabled = !state.validation.valid;
document.documentElement.dataset.appReady = "true";
diff --git a/apps/acoustic-camera/simulation-worker.js b/apps/acoustic-camera/simulation-worker.js
index 8042150..91208f5 100644
--- a/apps/acoustic-camera/simulation-worker.js
+++ b/apps/acoustic-camera/simulation-worker.js
@@ -5,6 +5,11 @@ import { localizationAlgorithmSupportsBackend } from "/packages/acoustics-core/l
import { computeLocalizationWithBackend } from "/packages/webgpu-kernels/compute.js";
import { isMusicUnavailableError } from "/packages/acoustics-core/music.js";
import { getCompleteFrameCount } from "/packages/acoustics-core/stft.js";
+import { analyzeCfarActivity } from "/packages/acoustics-core/activity-monitor.js";
+import {
+ computeDixonSpectralFlux,
+ pickCausalSpectralFluxPeaks,
+} from "/packages/acoustics-core/spectral-flux.js";
function post(type, jobId, payload = {}, transfer = []) {
self.postMessage({ type, jobId, ...payload }, transfer);
@@ -50,6 +55,219 @@ function collectBandTransfers(run, transfer) {
}
}
+function collectTypedArrayTransfers(value, transfer, seen = new Set(), buffers = new Set()) {
+ if (value === null || typeof value !== "object" || seen.has(value)) return;
+ seen.add(value);
+ if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
+ if (!buffers.has(value.buffer)) {
+ buffers.add(value.buffer);
+ transfer.push(value.buffer);
+ }
+ return;
+ }
+ if (Array.isArray(value)) {
+ for (const item of value) collectTypedArrayTransfers(item, transfer, seen, buffers);
+ return;
+ }
+ for (const item of Object.values(value)) {
+ collectTypedArrayTransfers(item, transfer, seen, buffers);
+ }
+}
+
+function activitySignalStats(synthesis, scene) {
+ const channel = synthesis.channels[0];
+ let selectedChannelPeak = 0;
+ for (const sample of channel) {
+ selectedChannelPeak = Math.max(selectedChannelPeak, Math.abs(sample));
+ }
+ const workingSetWithoutSourceIdentity = {
+ model: synthesis.workingSet.model,
+ channelBytes: synthesis.workingSet.channelBytes,
+ maximumTransientBytes: synthesis.workingSet.maximumTransientBytes,
+ maximumTransientScope: synthesis.workingSet.maximumTransientScope,
+ pathSummary: synthesis.workingSet.pathSummary,
+ summaryPathPairEvaluations:
+ synthesis.workingSet.summaryPathPairEvaluations,
+ synthesisPathBuildPairEvaluations:
+ synthesis.workingSet.synthesisPathBuildPairEvaluations,
+ retainedPathObjects: synthesis.workingSet.retainedPathObjects,
+ remainingNonToneTransformModel:
+ synthesis.workingSet.remainingNonToneTransformModel,
+ };
+ const windowingWithoutSourceTruth = {
+ model: synthesis.windowing.model,
+ renderStartSample: synthesis.windowing.renderStartSample,
+ outputStartSample: synthesis.windowing.outputStartSample,
+ outputSampleCount: synthesis.windowing.outputSampleCount,
+ preRollSamples: synthesis.windowing.preRollSamples,
+ renderMaximumPropagationDelaySeconds:
+ synthesis.windowing.renderMaximumPropagationDelaySeconds,
+ activePathMaximumDelaySeconds:
+ synthesis.windowing.activePathMaximumDelaySeconds,
+ requestedMaximumPropagationDelaySeconds:
+ synthesis.windowing.requestedMaximumPropagationDelaySeconds,
+ geometryModel: synthesis.windowing.geometryModel,
+ sourcePositionSampleTime: synthesis.windowing.sourcePositionSampleTime,
+ dopplerMode: synthesis.windowing.dopplerMode,
+ delayedTailPolicy: synthesis.windowing.delayedTailPolicy,
+ arrivalOverlapModel: synthesis.windowing.arrivalOverlapModel,
+ };
+ const peakNormalization = scene.mix?.normalization === "peak";
+ return {
+ preNormalizationPeak: synthesis.preNormalizationPeak,
+ appliedScale: synthesis.appliedScale,
+ clippedSampleCount: synthesis.clippedSampleCount,
+ selectedChannelPeak,
+ acousticModel: synthesis.acousticModel,
+ acousticPathCount: synthesis.acousticPathCount,
+ acousticPathCounts: synthesis.acousticPathCounts,
+ workingSet: workingSetWithoutSourceIdentity,
+ windowing: windowingWithoutSourceTruth,
+ preprocessing: {
+ configuredNormalization: scene.mix?.normalization ?? "none",
+ normalizationScope: peakNormalization
+ ? "selected-physical-microphone-whole-render"
+ : "none",
+ futureDependentNormalization: peakNormalization,
+ detectorCausalAfterPreprocessing: true,
+ endToEndCausal: !peakNormalization,
+ },
+ samplingModel: "ideal-real-sampler-no-antialias-filter",
+ sampleRate: synthesis.sampleRate,
+ nyquistHz: synthesis.sampleRate / 2,
+ explicitSceneSourceIdentitiesAndArrivalsIncluded: false,
+ sourceDerivedAggregatePhysicalAuditIncluded: true,
+ };
+}
+
+async function runActivity({
+ jobId,
+ scene,
+ arrayDefinition,
+ experiment,
+ assetBuffers,
+ activityConfig,
+}) {
+ if (!activityConfig || typeof activityConfig !== "object") {
+ throw new TypeError("activityConfig must be an object.");
+ }
+ const channelIndex = activityConfig.channelIndex;
+ if (
+ !Number.isInteger(channelIndex) ||
+ channelIndex < 0 ||
+ channelIndex >= arrayDefinition.microphones.length
+ ) {
+ throw new RangeError("activityConfig.channelIndex is outside the microphone array.");
+ }
+ const microphone = arrayDefinition.microphones[channelIndex];
+ const totalStartedAt = performance.now();
+ post("progress", jobId, {
+ stage: "activity-signal",
+ message: "전 대역 감시용 마이크 수신 신호를 합성하고 있습니다…",
+ });
+ const signalStartedAt = performance.now();
+ const selectedMicrophoneArray = {
+ ...arrayDefinition,
+ microphones: [microphone],
+ };
+ const synthesis = synthesizeMicrophoneSignals({
+ scene,
+ arrayDefinition: selectedMicrophoneArray,
+ assetBuffers,
+ minimumDistance: experiment.beamforming.minimumDistance,
+ });
+ const signalGeneration = performance.now() - signalStartedAt;
+ const samples = synthesis.channels[0];
+ const detectorStartedAt = performance.now();
+ let analysis;
+ let peakPicking = null;
+
+ if (
+ activityConfig.algorithm === "ca-cfar" ||
+ activityConfig.algorithm === "os-cfar"
+ ) {
+ analysis = await analyzeCfarActivity({
+ samples,
+ sampleRate: synthesis.sampleRate,
+ fftSize: experiment.stft.fftSize,
+ hopSize: experiment.stft.hopSize,
+ window: experiment.stft.window,
+ channelId: microphone.id,
+ detector: activityConfig.algorithm,
+ trainingCellsPerSide: activityConfig.trainingCellsPerSide,
+ guardCellsPerSide: activityConfig.guardCellsPerSide,
+ nominalFalseAlarmProbability:
+ activityConfig.nominalFalseAlarmProbability,
+ ...(activityConfig.algorithm === "os-cfar"
+ ? { rank: activityConfig.rank }
+ : {}),
+ onProgress: (progress) => post("progress", jobId, {
+ stage: progress.stage,
+ progress,
+ }),
+ });
+ } else if (activityConfig.algorithm === "dixon-spectral-flux") {
+ post("progress", jobId, {
+ stage: "activity-spectral-flux",
+ message: "Dixon spectral flux 원시 onset novelty를 계산하고 있습니다…",
+ });
+ analysis = computeDixonSpectralFlux({
+ samples,
+ sampleRate: synthesis.sampleRate,
+ fftSize: experiment.stft.fftSize,
+ hopSize: experiment.stft.hopSize,
+ channelId: microphone.id,
+ maximumOutputBytes: activityConfig.maximumOutputBytes,
+ });
+ if (activityConfig.peakPicker) {
+ peakPicking = pickCausalSpectralFluxPeaks(
+ analysis.rawOdf,
+ activityConfig.peakPicker,
+ );
+ }
+ } else {
+ throw new RangeError(
+ 'activityConfig.algorithm must be "ca-cfar", "os-cfar", or "dixon-spectral-flux".',
+ );
+ }
+
+ const detectorTime = performance.now() - detectorStartedAt;
+ const report = {
+ kind: "micarrsim.activity-worker-report/1",
+ algorithm: activityConfig.algorithm,
+ channel: {
+ index: channelIndex,
+ id: microphone.id,
+ position: [...microphone.position],
+ selection: "user-selected-single-physical-microphone",
+ microphoneFusion: "none",
+ },
+ input: {
+ sampleRate: synthesis.sampleRate,
+ sampleCount: samples.length,
+ fftSize: experiment.stft.fftSize,
+ hopSize: experiment.stft.hopSize,
+ experimentWindow: experiment.stft.window,
+ adcRangeHz: [0, synthesis.sampleRate / 2],
+ analogAliasOrderResolved: false,
+ sceneSourceTruthUsedByPhysicalSimulator: true,
+ sceneSourceTruthPassedToDetector: false,
+ synthesizedMicrophoneCount: 1,
+ },
+ signalStats: activitySignalStats(synthesis, scene),
+ analysis,
+ ...(peakPicking ? { peakPicking } : {}),
+ timingMs: {
+ total: performance.now() - totalStartedAt,
+ signalGeneration,
+ detector: detectorTime,
+ },
+ };
+ const transfer = [];
+ collectTypedArrayTransfers(report, transfer);
+ post("activity-result", jobId, { activityReport: report }, transfer);
+}
+
async function computeLocalizationOrUnavailable(options) {
try {
return await computeLocalizationWithBackend(options);
@@ -255,10 +473,18 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
}
self.addEventListener("message", async (event) => {
- if (event.data?.type !== "run" && event.data?.type !== "run-timeline") return;
+ if (
+ event.data?.type !== "run" &&
+ event.data?.type !== "run-timeline" &&
+ event.data?.type !== "detect-activity"
+ ) return;
const { jobId, scene, arrayDefinition, experiment, assetBuffers } = event.data;
const totalStartedAt = performance.now();
try {
+ if (event.data.type === "detect-activity") {
+ await runActivity(event.data);
+ return;
+ }
if (event.data.type === "run-timeline") {
await runTimeline(event.data);
return;
diff --git a/apps/acoustic-camera/styles.css b/apps/acoustic-camera/styles.css
index 9166274..9a30b55 100644
--- a/apps/acoustic-camera/styles.css
+++ b/apps/acoustic-camera/styles.css
@@ -300,6 +300,193 @@
.band-editor input[type="color"] { padding: 3px; }
.band-editor__remove { border: 0; border-radius: 7px; color: var(--danger); background: rgb(255 123 115 / 8%); cursor: pointer; }
+.activity-monitor__body {
+ display: grid;
+ min-width: 0;
+ gap: 9px;
+ padding: 10px;
+ border-top: 1px solid var(--line);
+}
+.activity-monitor__copy,
+.activity-monitor__note,
+.activity-status,
+.activity-result-empty {
+ margin: 0;
+ overflow-wrap: anywhere;
+ font-size: .6rem;
+ line-height: 1.5;
+}
+.activity-monitor__copy { color: var(--muted); }
+.activity-field-grid,
+.activity-option-grid,
+.activity-actions {
+ display: grid;
+ min-width: 0;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+.activity-option-grid {
+ padding: 9px;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ background: #0b1714;
+}
+.activity-option-grid[hidden] { display: none; }
+.activity-checkbox {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 7px;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: .61rem;
+ font-weight: 650;
+}
+.activity-checkbox input {
+ width: 16px;
+ height: 16px;
+ flex: 0 0 auto;
+ margin: 0;
+ accent-color: var(--mint);
+}
+.activity-monitor__note {
+ padding: 8px 9px;
+ border-left: 2px solid rgb(88 199 232 / 45%);
+ border-radius: 0 7px 7px 0;
+ color: var(--muted);
+ background: rgb(88 199 232 / 4%);
+}
+.activity-actions { margin-top: 1px; }
+.activity-status {
+ min-width: 0;
+ padding: 7px 8px;
+ border-radius: 7px;
+ color: var(--muted);
+ background: #0b1714;
+}
+.activity-frame-control {
+ display: grid;
+ min-width: 0;
+ gap: 5px;
+ color: var(--muted);
+ font-size: .59rem;
+ font-weight: 650;
+}
+.activity-frame-control input { width: 100%; min-width: 0; accent-color: var(--cyan); }
+.activity-result-list {
+ display: grid;
+ overflow-y: auto;
+ min-width: 0;
+ min-height: 140px;
+ max-height: min(52vh, 520px);
+ gap: 6px;
+ padding: 8px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #07110f;
+ overscroll-behavior: contain;
+ scrollbar-width: thin;
+ resize: vertical;
+}
+.activity-result-empty { color: var(--muted); text-align: center; }
+.activity-result-item {
+ min-width: 0;
+ padding: 7px 8px;
+ border: 1px solid var(--line);
+ border-radius: 7px;
+ background: #0b1714;
+ overflow-wrap: anywhere;
+}
+.activity-message,
+.activity-caveat,
+.activity-frame-summary,
+.activity-empty,
+.activity-event-selected {
+ margin: 0;
+ padding: 7px 8px;
+ border-radius: 7px;
+ overflow-wrap: anywhere;
+ font-size: .6rem;
+ line-height: 1.5;
+}
+.activity-message,
+.activity-frame-summary {
+ color: var(--text);
+ background: #0b1714;
+}
+.activity-message.is-stale {
+ color: var(--amber);
+ border: 1px solid rgb(255 197 110 / 24%);
+}
+.activity-caveat {
+ color: var(--muted);
+ border-left: 2px solid rgb(88 199 255 / 42%);
+ background: rgb(88 199 255 / 5%);
+}
+.activity-empty { color: var(--muted); text-align: center; }
+.activity-event-selected {
+ color: var(--mint);
+ border: 1px solid rgb(101 242 191 / 24%);
+ background: rgb(101 242 191 / 5%);
+}
+.activity-result-row {
+ display: grid;
+ min-width: 0;
+ gap: 5px;
+ padding: 8px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #0b1714;
+}
+.activity-result-row > strong {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ color: var(--text);
+ font-size: .64rem;
+}
+.activity-result-row > span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ color: var(--muted);
+ font: 550 .57rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
+}
+.activity-hypothesis {
+ display: grid;
+ min-width: 0;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 5px;
+}
+.activity-hypothesis > span {
+ grid-column: 1 / -1;
+ color: var(--muted);
+ font-size: .56rem;
+}
+.activity-hypothesis input {
+ width: 100%;
+ min-width: 0;
+ min-height: 32px;
+ padding: 6px 7px;
+ border: 1px solid var(--line);
+ border-radius: 7px;
+ color: var(--text);
+ background: #07110f;
+ font-size: .62rem;
+}
+.activity-event-button {
+ width: 100%;
+ min-width: 0;
+ padding: 7px 8px;
+ border: 1px solid var(--line);
+ border-radius: 7px;
+ color: var(--text);
+ background: #0b1714;
+ cursor: pointer;
+ overflow-wrap: anywhere;
+ text-align: left;
+ font: 550 .59rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
+}
+.activity-event-button:hover { border-color: var(--cyan); }
+
details > summary { list-style: none; }
details > summary::-webkit-details-marker { display: none; }
.inline-details, .advanced-settings, .utility-details, .mode-help {
@@ -470,22 +657,35 @@ html[data-run-state="running"] #snapshot-mode { border-color: var(--mint); box-s
.header-status { grid-column: 1 / -1; }
.run-status { max-width: 100%; }
.workflow-strip { top: 88px; }
- .input-file-grid, .quick-field-grid, .algorithm-options, .advanced-field-grid, .output-actions, .band-editor, .live-controls { grid-template-columns: 1fr; }
+ .input-file-grid, .quick-field-grid, .algorithm-options, .advanced-field-grid, .output-actions, .band-editor, .live-controls, .activity-field-grid, .activity-option-grid, .activity-actions { grid-template-columns: 1fr; }
.field--wide, .live-scrubber { grid-column: auto; }
.band-editor__remove { min-height: 32px; }
.live-actions .button { flex: 1 1 calc(50% - 4px); }
.metric-grid { grid-template-columns: 1fr 1fr; }
.overlay-toolbar { justify-content: flex-start; }
+ .activity-hypothesis { grid-template-columns: 1fr; }
+ .activity-hypothesis > span { grid-column: auto; }
}
@container camera-controls (max-width: 350px) {
.input-file-grid,
.quick-field-grid,
.advanced-field-grid,
- .output-actions {
+ .output-actions,
+ .activity-field-grid,
+ .activity-option-grid,
+ .activity-actions {
grid-template-columns: minmax(0, 1fr);
}
+ .activity-hypothesis {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .activity-hypothesis > span {
+ grid-column: auto;
+ }
+
.field--wide {
grid-column: auto;
}
diff --git a/packages/acoustics-core/spectral-flux.js b/packages/acoustics-core/spectral-flux.js
index 23e4bba..f8b9404 100644
--- a/packages/acoustics-core/spectral-flux.js
+++ b/packages/acoustics-core/spectral-flux.js
@@ -4,7 +4,8 @@ const DIXON_2006_URL =
"https://www.dafx.de/paper-archive/2006/papers/p_133.pdf";
const BOECK_KREBS_SCHEDL_2012_URL =
"https://www.cp.jku.at/research/papers/Boeck_etal_ISMIR_2012.pdf";
-const DEFAULT_MAXIMUM_OUTPUT_BYTES = 64 * 1024 * 1024;
+export const DEFAULT_MAXIMUM_SPECTRAL_FLUX_OUTPUT_BYTES =
+ 64 * 1024 * 1024;
function requireConfigurationObject(value, label) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
@@ -92,6 +93,90 @@ function checkedByteCount(elementCount, bytesPerElement, label) {
return byteCount;
}
+export function estimateDixonSpectralFluxWorkload(options) {
+ const configuration = requireConfigurationObject(options, "options");
+ const sampleCount = requireNonNegativeSafeInteger(
+ requireOwn(configuration, "sampleCount"),
+ "sampleCount",
+ );
+ const fftSize = requirePowerOfTwo(
+ requireOwn(configuration, "fftSize"),
+ "fftSize",
+ );
+ const hopSize = requireNonNegativeSafeInteger(
+ requireOwn(configuration, "hopSize"),
+ "hopSize",
+ );
+ if (hopSize < 1 || hopSize > fftSize) {
+ throw new RangeError("hopSize must be between 1 and fftSize, inclusive.");
+ }
+ const maximumOutputBytes =
+ configuration.maximumOutputBytes ??
+ DEFAULT_MAXIMUM_SPECTRAL_FLUX_OUTPUT_BYTES;
+ if (!Number.isSafeInteger(maximumOutputBytes) || maximumOutputBytes <= 0) {
+ throw new RangeError("maximumOutputBytes must be a positive safe integer.");
+ }
+ const frameCount = sampleCount < fftSize
+ ? 0
+ : 1 + Math.floor((sampleCount - fftSize) / hopSize);
+ const binCount = fftSize;
+ const positiveDifferenceElements =
+ checkedElementCount(frameCount, binCount);
+ const positiveDifferenceBytes = checkedByteCount(
+ positiveDifferenceElements,
+ Float64Array.BYTES_PER_ELEMENT,
+ "positiveDifferences",
+ );
+ const rawOdfBytes = checkedByteCount(
+ frameCount,
+ Float64Array.BYTES_PER_ELEMENT,
+ "rawOdf",
+ );
+ const frameTimingBytes = checkedByteCount(
+ frameCount * 3,
+ Float64Array.BYTES_PER_ELEMENT,
+ "frame timing",
+ );
+ const outputBytes =
+ positiveDifferenceBytes + rawOdfBytes + frameTimingBytes;
+ if (!Number.isSafeInteger(outputBytes)) {
+ throw new RangeError(
+ "spectral-flux output byte count exceeds safe integer range.",
+ );
+ }
+ if (outputBytes > maximumOutputBytes) {
+ throw new RangeError(
+ `spectral flux requires ${outputBytes.toLocaleString()} output bytes; ` +
+ `maximumOutputBytes is ${maximumOutputBytes.toLocaleString()}.`,
+ );
+ }
+ const workspaceElements = frameCount === 0 ? 0 : fftSize * 4;
+ const workspaceBytes = checkedByteCount(
+ workspaceElements,
+ Float64Array.BYTES_PER_ELEMENT,
+ "spectral-flux workspace",
+ );
+ const estimatedPeakTypedArrayBytes = outputBytes + workspaceBytes;
+ if (!Number.isSafeInteger(estimatedPeakTypedArrayBytes)) {
+ throw new RangeError(
+ "spectral-flux peak typed-array byte count exceeds safe integer range.",
+ );
+ }
+ return Object.freeze({
+ sampleCount,
+ frameCount,
+ binCount,
+ positiveDifferenceElements,
+ positiveDifferenceBytes,
+ rawOdfBytes,
+ frameTimingBytes,
+ outputBytes,
+ maximumOutputBytes,
+ workspaceBytes,
+ estimatedPeakTypedArrayBytes,
+ });
+}
+
/**
* Compute Simon Dixon's 2006 magnitude spectral-flux onset detection function.
*
@@ -123,51 +208,24 @@ export function computeDixonSpectralFlux(options) {
if (typeof channelId !== "string" || channelId.length === 0) {
throw new TypeError("channelId must be a non-empty string.");
}
- const maximumOutputBytes = configuration.maximumOutputBytes ?? DEFAULT_MAXIMUM_OUTPUT_BYTES;
- if (!Number.isSafeInteger(maximumOutputBytes) || maximumOutputBytes <= 0) {
- throw new RangeError("maximumOutputBytes must be a positive safe integer.");
- }
-
- const frameCount = samples.length < fftSize
- ? 0
- : 1 + Math.floor((samples.length - fftSize) / hopSize);
- const binCount = fftSize;
- const positiveDifferenceElements = checkedElementCount(frameCount, binCount);
- const positiveDifferenceBytes = checkedByteCount(
- positiveDifferenceElements,
- Float64Array.BYTES_PER_ELEMENT,
- "positiveDifferences",
- );
- const rawOdfBytes = checkedByteCount(
+ const workload = estimateDixonSpectralFluxWorkload({
+ sampleCount: samples.length,
+ fftSize,
+ hopSize,
+ maximumOutputBytes: configuration.maximumOutputBytes,
+ });
+ const {
frameCount,
- Float64Array.BYTES_PER_ELEMENT,
- "rawOdf",
- );
- const frameTimingBytes = checkedByteCount(
- frameCount * 3,
- Float64Array.BYTES_PER_ELEMENT,
- "frame timing",
- );
- const outputBytes = positiveDifferenceBytes + rawOdfBytes + frameTimingBytes;
- if (!Number.isSafeInteger(outputBytes)) {
- throw new RangeError("spectral-flux output byte count exceeds safe integer range.");
- }
- if (outputBytes > maximumOutputBytes) {
- throw new RangeError(
- `spectral flux requires ${outputBytes.toLocaleString()} output bytes; ` +
- `maximumOutputBytes is ${maximumOutputBytes.toLocaleString()}.`,
- );
- }
- const workspaceElements = frameCount === 0 ? 0 : fftSize * 4;
- const workspaceBytes = checkedByteCount(
- workspaceElements,
- Float64Array.BYTES_PER_ELEMENT,
- "spectral-flux workspace",
- );
- const estimatedPeakTypedArrayBytes = outputBytes + workspaceBytes;
- if (!Number.isSafeInteger(estimatedPeakTypedArrayBytes)) {
- throw new RangeError("spectral-flux peak typed-array byte count exceeds safe integer range.");
- }
+ binCount,
+ positiveDifferenceElements,
+ positiveDifferenceBytes,
+ rawOdfBytes,
+ frameTimingBytes,
+ outputBytes,
+ maximumOutputBytes,
+ workspaceBytes,
+ estimatedPeakTypedArrayBytes,
+ } = workload;
const positiveDifferences = new Float64Array(positiveDifferenceElements);
const rawOdf = new Float64Array(frameCount);
diff --git a/tests/integration/camera-ui.test.js b/tests/integration/camera-ui.test.js
index 46c3c47..3f82567 100644
--- a/tests/integration/camera-ui.test.js
+++ b/tests/integration/camera-ui.test.js
@@ -853,3 +853,579 @@ test("Acoustic Camera prepares, scrubs, plays, and pauses the moving-source exam
frame.remove();
}
});
+
+test("Acoustic Camera full-band Activity keeps raw ADC evidence separate from localization", async () => {
+ const frame = document.createElement("iframe");
+ frame.src = "/apps/acoustic-camera/?integration-test=activity-full-band-1";
+ frame.style.cssText =
+ "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
+ document.body.append(frame);
+ await new Promise((resolve, reject) => {
+ frame.addEventListener("load", resolve, { once: true });
+ frame.addEventListener(
+ "error",
+ () => reject(new Error("Acoustic Camera Activity iframe load failed")),
+ { once: true },
+ );
+ });
+
+ const windowObject = frame.contentWindow;
+ const documentObject = frame.contentDocument;
+ const downloads = [];
+ const objectUrls = [];
+ let confirmCount = 0;
+ const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
+ const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
+ const originalConfirm = windowObject.confirm.bind(windowObject);
+ windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
+ downloads.push({ fileName: this.download, url: this.href });
+ };
+ windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url);
+ windowObject.confirm = () => {
+ confirmCount += 1;
+ return true;
+ };
+
+ try {
+ await waitFor(
+ () => documentObject.documentElement.dataset.appReady === "true",
+ "Acoustic Camera Activity initialization",
+ );
+ await waitFor(
+ () => documentObject.documentElement.dataset.cameraValid === "true",
+ "Acoustic Camera Activity initial validation",
+ );
+ assert(
+ documentObject.querySelector("#activity-export-button").disabled,
+ "Activity export starts disabled without a report",
+ );
+ assert(
+ documentObject.querySelector("#activity-frame").disabled,
+ "Activity frame scrubber starts disabled without a report",
+ );
+
+ documentObject.querySelector("#load-music-example-button").click();
+ await waitFor(
+ () =>
+ documentObject.documentElement.dataset.inputBusy === "false" &&
+ documentObject.documentElement.dataset.cameraValid === "true" &&
+ documentObject.querySelector("#algorithm").value === "music" &&
+ documentObject.querySelector("#microphone-count").textContent === "12 ch",
+ "Activity quick MUSIC example load",
+ );
+ const sceneWithMissingBackground = await loadJson(
+ "/assets/sample-scenes/music-single-tone.scene.json",
+ );
+ sceneWithMissingBackground.mix.normalization = "peak";
+ sceneWithMissingBackground.assets = [
+ {
+ id: "unbound-background",
+ kind: "image",
+ fileName: "unbound-background.png",
+ mimeType: "image/png",
+ },
+ ];
+ sceneWithMissingBackground.image = {
+ assetId: "unbound-background",
+ widthPixels: 640,
+ heightPixels: 480,
+ plane: {
+ z: 2,
+ xMin: -1,
+ xMax: 1,
+ yMin: -0.75,
+ yMax: 0.75,
+ },
+ };
+ dispatchFiles(
+ windowObject,
+ documentObject.querySelector("#scene-input"),
+ [
+ new windowObject.File(
+ [JSON.stringify(sceneWithMissingBackground)],
+ "music-with-unbound-background.scene.json",
+ { type: "application/json" },
+ ),
+ ],
+ );
+ await waitFor(
+ () =>
+ documentObject.documentElement.dataset.inputBusy === "false" &&
+ documentObject.documentElement.dataset.cameraValid === "false" &&
+ documentObject.querySelector("#validation-message").textContent.includes(
+ "unbound-background",
+ ) &&
+ !documentObject.querySelector("#activity-run-button").disabled,
+ "missing background image does not block audio Activity analysis",
+ );
+ const activityMonitor = documentObject.querySelector("#activity-monitor");
+ activityMonitor.open = true;
+ assertEqual(
+ documentObject.querySelector("#activity-algorithm").value,
+ "os-cfar",
+ "default full-band detector is OS-CFAR",
+ );
+ assert(
+ activityMonitor.querySelector(".activity-monitor__copy").textContent.includes(
+ "Scene",
+ ) &&
+ activityMonitor.querySelector(".activity-monitor__copy").textContent.includes(
+ "검출기에 전달하지",
+ ) &&
+ activityMonitor.querySelector(".activity-monitor__copy").textContent.includes(
+ "ADC",
+ ) &&
+ activityMonitor.querySelector(".activity-monitor__copy").textContent.includes(
+ "alias order",
+ ),
+ "Activity copy separates ADC observation from Scene truth and analog alias order",
+ );
+ const musicBandMaximum = documentObject.querySelector(
+ '#band-list .band-editor input[data-band-key="maxHz"]',
+ );
+ const originalMusicBandMaximum = musicBandMaximum.valueAsNumber;
+ dispatchValue(
+ windowObject,
+ musicBandMaximum,
+ originalMusicBandMaximum + 2 * (48000 / 512),
+ );
+ await waitFor(
+ () =>
+ documentObject.documentElement.dataset.cameraValid === "false" &&
+ documentObject.querySelector("#run-button").disabled &&
+ !documentObject.querySelector("#activity-run-button").disabled,
+ "Activity remains ready when an unused MUSIC band is invalid",
+ );
+
+ documentObject.querySelector("#activity-run-button").click();
+ await waitFor(
+ () =>
+ documentObject.documentElement.dataset.activityState === "done" &&
+ documentObject.documentElement.dataset.activityAlgorithm === "os-cfar",
+ "default OS-CFAR Activity run",
+ 45000,
+ );
+ dispatchValue(
+ windowObject,
+ documentObject.querySelector(
+ '#band-list .band-editor input[data-band-key="maxHz"]',
+ ),
+ originalMusicBandMaximum,
+ );
+ await waitFor(
+ () =>
+ documentObject.documentElement.dataset.cameraValid === "false" &&
+ documentObject.documentElement.dataset.activityState === "done" &&
+ documentObject.querySelector("#validation-message").textContent.includes(
+ "unbound-background",
+ ),
+ "restore localization-only MUSIC band after independent Activity run",
+ );
+ assertEqual(
+ documentObject.documentElement.dataset.runState,
+ "idle",
+ "Activity run does not create a localization result",
+ );
+ assert(
+ documentObject.querySelector("#export-result-button").disabled,
+ "localization Result export remains unavailable",
+ );
+ const caveatText =
+ documentObject.querySelector("#activity-result-list .activity-caveat")
+ ?.textContent ?? "";
+ assert(
+ caveatText.includes("IID exponential") &&
+ caveatText.includes("tested CUT") &&
+ caveatText.includes("confidence") &&
+ caveatText.includes("frame 전체"),
+ "UI states the nominal per-CUT statistical limits",
+ );
+ const activityResultText =
+ documentObject.querySelector("#activity-result-list").textContent;
+ assert(
+ documentObject.querySelector("#activity-frame-label").textContent.includes(
+ "start",
+ ) &&
+ documentObject.querySelector("#activity-frame-label").textContent.includes(
+ "available",
+ ),
+ "Activity frame distinguishes descriptive start from causal availability",
+ );
+ assert(
+ activityResultText.includes("CFAR tested 범위") &&
+ activityResultText.includes("full reference window") &&
+ activityResultText.includes("untested"),
+ "UI discloses the actual CFAR coverage and untouched edge bins",
+ );
+ assert(
+ activityResultText.includes("end-to-end 전처리는 미래 sample에 의존"),
+ "UI discloses whole-render peak-normalization causality",
+ );
+
+ const toneHypothesisInput = await waitFor(
+ () => documentObject.querySelector(
+ '#activity-result-list input[data-activity-analog-hypothesis="true"]' +
+ '[data-activity-bin="12"]',
+ ),
+ "detected 1125 Hz raw Activity row",
+ 15000,
+ );
+ const toneRow = toneHypothesisInput.closest(".activity-result-row");
+ assert(
+ toneRow.querySelector("strong").textContent.includes("ADC bin 12") &&
+ toneRow.querySelector("strong").textContent.includes("1125"),
+ "detected tone is expressed as an observed ADC bin",
+ );
+ const rawText = [...toneRow.children]
+ .find((child) => child.tagName === "SPAN")?.textContent ?? "";
+ assert(
+ rawText.includes("CUT") &&
+ rawText.includes("ref") &&
+ rawText.includes("threshold") &&
+ rawText.includes("ratio"),
+ "detected row exposes raw CUT, reference, threshold, and ratio",
+ );
+ assertEqual(
+ toneHypothesisInput.valueAsNumber,
+ 1125,
+ "analog hypothesis starts at the observed ADC center without claiming alias order",
+ );
+
+ dispatchValue(windowObject, toneHypothesisInput, 49125, "input");
+ toneRow.querySelector("button[data-add-activity-band]").click();
+ const addedBandEditor = await waitFor(
+ () => [...documentObject.querySelectorAll("#band-list .band-editor")]
+ .find((editor) =>
+ editor.querySelector('input[data-band-key="id"]')?.value ===
+ "activity-1"),
+ "above-Nyquist Activity hypothesis band addition",
+ );
+ const addedMinimumHz = Number(
+ addedBandEditor.querySelector('input[data-band-key="minHz"]').value,
+ );
+ const addedMaximumHz = Number(
+ addedBandEditor.querySelector('input[data-band-key="maxHz"]').value,
+ );
+ assert(
+ Math.abs((addedMinimumHz + addedMaximumHz) / 2 - 49125) <= 1e-9,
+ "explicit analog hypothesis center",
+ );
+ assert(
+ Math.abs(addedMaximumHz - addedMinimumHz - 48000 / 512) <= 1e-9,
+ "one FFT-cell candidate band width",
+ );
+ assert(
+ (addedMinimumHz + addedMaximumHz) / 2 > 48000 / 2,
+ "above-Nyquist analog hypothesis is retained",
+ );
+ await waitFor(
+ () =>
+ documentObject.documentElement.dataset.cameraValid === "false" &&
+ documentObject.documentElement.dataset.activityState === "done" &&
+ documentObject.querySelector("#validation-message").textContent.includes(
+ "unbound-background",
+ ),
+ "above-Nyquist Activity band with unrelated missing image",
+ );
+ assert(
+ documentObject.querySelector("#run-status").textContent.includes("ADC bin 12") &&
+ documentObject.querySelector("#run-status").textContent.includes(
+ "다른 alias order",
+ ),
+ "band addition labels ADC folding and keeps alias-order choice explicit",
+ );
+
+ const activityDownloadStart = downloads.length;
+ documentObject.querySelector("#activity-export-button").click();
+ const activityDownload = await waitFor(
+ () => downloads.slice(activityDownloadStart)
+ .find((download) => download.fileName.endsWith(".activity.json")),
+ "OS-CFAR Activity JSON download",
+ );
+ const activityDocument = await fetch(activityDownload.url).then(
+ (response) => response.json(),
+ );
+ assertEqual(activityDocument.kind, "micarrsim.activity-report", "Activity document kind");
+ assertEqual(activityDocument.provenanceOnlyIds, true, "Scene/Array/Experiment are provenance IDs");
+ assertEqual(
+ activityDocument.detectorSceneTruthInput,
+ false,
+ "outer Activity report denies Scene-truth detector input",
+ );
+ const workerReport = activityDocument.report;
+ const analysis = workerReport.analysis;
+ assertEqual(workerReport.algorithm, "os-cfar", "exported detector identity");
+ assertEqual(
+ workerReport.channel.selection,
+ "user-selected-single-physical-microphone",
+ "one explicit physical microphone",
+ );
+ assertEqual(workerReport.channel.microphoneFusion, "none", "no microphone fusion");
+ assertEqual(
+ workerReport.input.sceneSourceTruthPassedToDetector,
+ false,
+ "worker does not receive Scene source truth as detector input",
+ );
+ assertEqual(
+ workerReport.input.sceneSourceTruthUsedByPhysicalSimulator,
+ true,
+ "report distinguishes physical Scene synthesis from detector input",
+ );
+ assertEqual(
+ workerReport.input.synthesizedMicrophoneCount,
+ 1,
+ "only the explicitly selected microphone is synthesized for Activity",
+ );
+ assertEqual(
+ workerReport.input.analogAliasOrderResolved,
+ false,
+ "worker reports ADC observation without resolving analog alias order",
+ );
+ assertEqual(
+ workerReport.input.adcRangeHz.join(","),
+ "0,24000",
+ "Activity export reports only the observable ADC range",
+ );
+ assertEqual(workerReport.localization, undefined, "worker report has no localization");
+ assertEqual(workerReport.scene, undefined, "worker report contains no Scene source list");
+ assertEqual(activityDocument.localization, undefined, "document has no localization payload");
+ assertEqual(
+ workerReport.signalStats.windowing.arrivingSourceIds,
+ undefined,
+ "Activity artifact omits arriving source identities",
+ );
+ assertEqual(
+ workerReport.signalStats
+ .explicitSceneSourceIdentitiesAndArrivalsIncluded,
+ false,
+ "report narrows its truth-free claim to explicit identities and arrivals",
+ );
+ assertEqual(
+ workerReport.signalStats.sourceDerivedAggregatePhysicalAuditIncluded,
+ true,
+ "report discloses aggregate source-derived physical audit statistics",
+ );
+ assertEqual(
+ workerReport.signalStats.preprocessing.normalizationScope,
+ "selected-physical-microphone-whole-render",
+ "peak normalization is scoped to the selected microphone",
+ );
+ assertEqual(
+ workerReport.signalStats.preprocessing.futureDependentNormalization,
+ true,
+ "whole-render peak normalization is marked future-dependent",
+ );
+ assertEqual(
+ workerReport.signalStats.preprocessing.endToEndCausal,
+ false,
+ "peak-normalized Activity pipeline does not claim end-to-end causality",
+ );
+ assertEqual(
+ workerReport.signalStats.windowing.sourceArrivals,
+ undefined,
+ "Activity artifact omits source-arrival truth",
+ );
+ assertEqual(
+ workerReport.signalStats.workingSet.maximumTransientId,
+ undefined,
+ "Activity artifact omits source-scoped working-set identity",
+ );
+
+ const model = analysis.detectorModel;
+ assertEqual(
+ model.parameters.nominalPfaPerTestedCutUnderIidExponentialModel,
+ 0.001,
+ "nominal Pfa remains explicitly model-qualified",
+ );
+ assertEqual(
+ model.calibration.status,
+ "nominal-model-only-not-empirically-calibrated",
+ "nominal Pfa is not represented as empirical calibration",
+ );
+ assertEqual(
+ model.calibration.globalFrameFalseAlarmProbability,
+ null,
+ "no invented frame-global false-alarm probability",
+ );
+ assert(
+ model.calibration.notCalibratedReasons.includes(
+ "hann-window-bin-independence-is-not-guaranteed",
+ ),
+ "Hann bin dependence limitation is exported",
+ );
+ assertEqual(model.processing.clustering, false, "no bin clustering");
+ assertEqual(model.processing.temporalPersistence, false, "no temporal persistence");
+ assertEqual(model.processing.detectorFusion, false, "no detector fusion");
+ assertEqual(model.processing.microphoneFusion, false, "no microphone fusion metadata");
+ assertEqual(model.processing.bandFusion, false, "no band fusion");
+
+ const expectedFrameCount =
+ 1 + Math.floor(
+ (workerReport.input.sampleCount - workerReport.input.fftSize) /
+ workerReport.input.hopSize,
+ );
+ const expectedBinCount = workerReport.input.fftSize / 2 + 1;
+ assertEqual(analysis.frames.length, expectedFrameCount, "every complete frame retained");
+ assertEqual(analysis.summary.frameCount, expectedFrameCount, "frame summary is lossless");
+ let exportedBinCount = 0;
+ for (const exportedFrame of analysis.frames) {
+ assertEqual(exportedFrame.bins.length, expectedBinCount, "every ADC bin retained");
+ assertEqual(
+ exportedFrame.testedBinCount + exportedFrame.untestedBinCount,
+ expectedBinCount,
+ "tested and untested states cover a whole frame",
+ );
+ for (let binIndex = 0; binIndex < exportedFrame.bins.length; binIndex += 1) {
+ const bin = exportedFrame.bins[binIndex];
+ assertEqual(bin.adcBin, binIndex, "ADC bin order is preserved");
+ assert(Number.isFinite(bin.cutPower) && bin.cutPower >= 0, "raw CUT power is finite");
+ assert(
+ bin.status === "detected" ||
+ bin.status === "not-detected" ||
+ bin.status === "untested",
+ "every bin has an explicit decision state",
+ );
+ }
+ exportedBinCount += exportedFrame.bins.length;
+ }
+ assertEqual(
+ exportedBinCount,
+ expectedFrameCount * expectedBinCount,
+ "Activity JSON preserves the full frame/bin Cartesian product",
+ );
+ assertEqual(
+ analysis.summary.binRecordCount,
+ exportedBinCount,
+ "raw bin-record summary matches the payload",
+ );
+ assertEqual(analysis.frames[0].bins[0].reason, "real-only-dc-bin", "DC remains untested");
+ assertEqual(
+ analysis.frames[0].bins.at(-1).reason,
+ "real-only-nyquist-bin",
+ "Nyquist remains untested",
+ );
+ const exportedTone = analysis.frames
+ .map((exportedFrame) => exportedFrame.bins[12])
+ .find((bin) => bin.status === "detected");
+ assert(exportedTone, "the UI row comes from a retained raw detected-bin record");
+ assert(
+ Number.isFinite(exportedTone.referencePower) &&
+ Number.isFinite(exportedTone.thresholdPower),
+ "detected tone retains raw reference and threshold statistics",
+ );
+ assert(
+ exportedTone.thresholdRatio === null
+ ? exportedTone.thresholdRatioStatus ===
+ "positive-infinity-zero-threshold"
+ : Number.isFinite(exportedTone.thresholdRatio) &&
+ exportedTone.thresholdRatio > 1,
+ "threshold ratio is raw and JSON-safe",
+ );
+
+ const experimentDownloadStart = downloads.length;
+ documentObject.querySelector("#export-experiment-button").click();
+ const experimentDownload = await waitFor(
+ () => downloads.slice(experimentDownloadStart)
+ .find((download) => download.fileName.endsWith(".experiment.json")),
+ "Activity hypothesis Experiment JSON download",
+ );
+ const experimentDocument = await fetch(experimentDownload.url).then(
+ (response) => response.json(),
+ );
+ const addedBand = experimentDocument.frequencyBands.find(
+ (band) => band.id === "activity-1",
+ );
+ assert(addedBand, "explicit Activity hypothesis is exported as an Experiment band");
+ assert(
+ Math.abs((addedBand.minHz + addedBand.maxHz) / 2 - 49125) <= 1e-9,
+ "exported analog center",
+ );
+ assert(
+ (addedBand.minHz + addedBand.maxHz) / 2 > workerReport.input.adcRangeHz[1],
+ "Experiment retains the above-Nyquist analog hypothesis",
+ );
+ const [musicArray, musicScene] = await Promise.all([
+ loadJson("/assets/array-geometries/rectangular-4x3-450mm.array.json"),
+ loadJson("/assets/sample-scenes/music-single-tone.scene.json"),
+ ]);
+ assertEqual(
+ (await validateExperiment(experimentDocument, {
+ arrayDefinition: musicArray,
+ scene: musicScene,
+ })).length,
+ 0,
+ "above-Nyquist explicit hypothesis remains a valid Experiment",
+ );
+ assertEqual(confirmCount, 0, "saved Activity report is replaced without a dirty-report prompt");
+
+ dispatchValue(
+ windowObject,
+ documentObject.querySelector("#activity-algorithm"),
+ "dixon-spectral-flux",
+ );
+ assert(
+ !documentObject.querySelector("#activity-peak-picker-enabled").checked,
+ "spectral-flux peak picker remains opt-in",
+ );
+ await waitFor(
+ () =>
+ !documentObject.querySelector("#activity-run-button").disabled &&
+ !documentObject.querySelector("#activity-flux-options").hidden,
+ "raw-only spectral flux readiness",
+ );
+ documentObject.querySelector("#activity-run-button").click();
+ await waitFor(
+ () =>
+ documentObject.documentElement.dataset.activityState === "done" &&
+ documentObject.documentElement.dataset.activityAlgorithm ===
+ "dixon-spectral-flux",
+ "raw-only spectral flux Activity run",
+ 45000,
+ );
+ const fluxText = documentObject.querySelector("#activity-result-list").textContent;
+ assert(
+ fluxText.includes("raw onset novelty") &&
+ fluxText.includes("probability") &&
+ fluxText.includes("Peak picker를 적용하지 않아"),
+ "spectral flux UI remains raw novelty without an invented event threshold",
+ );
+ const fluxDownloadStart = downloads.length;
+ documentObject.querySelector("#activity-export-button").click();
+ const fluxDownload = await waitFor(
+ () => downloads.slice(fluxDownloadStart)
+ .find((download) => download.fileName.endsWith(".activity.json")),
+ "raw spectral-flux Activity JSON download",
+ );
+ const fluxDocument = await fetch(fluxDownload.url).then(
+ (response) => response.json(),
+ );
+ assertEqual(
+ fluxDocument.report.algorithm,
+ "dixon-spectral-flux",
+ "raw spectral-flux identity",
+ );
+ assertEqual(
+ fluxDocument.report.peakPicking,
+ undefined,
+ "no implicit spectral-flux peak picker",
+ );
+ assertEqual(
+ fluxDocument.report.analysis.rawOdf.length,
+ fluxDocument.report.analysis.frameCount,
+ "all raw spectral-flux frame values retained",
+ );
+ assertEqual(
+ fluxDocument.report.analysis.positiveDifferences.length,
+ fluxDocument.report.analysis.frameCount *
+ fluxDocument.report.analysis.binCount,
+ "all raw per-frame/per-bin positive differences retained",
+ );
+ assertEqual(confirmCount, 0, "Activity exports clear report-dirty state");
+ } finally {
+ windowObject.confirm = originalConfirm;
+ windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
+ windowObject.URL.revokeObjectURL = originalRevoke;
+ for (const url of objectUrls) originalRevoke(url);
+ frame.remove();
+ }
+});
diff --git a/tests/unit/spectral-flux.test.js b/tests/unit/spectral-flux.test.js
index 0fade05..3e7f39a 100644
--- a/tests/unit/spectral-flux.test.js
+++ b/tests/unit/spectral-flux.test.js
@@ -1,5 +1,6 @@
import {
computeDixonSpectralFlux,
+ estimateDixonSpectralFluxWorkload,
pickCausalSpectralFluxPeaks,
} from "/packages/acoustics-core/spectral-flux.js";
@@ -90,6 +91,19 @@ test("Dixon flux는 Hamming linear magnitude의 양의 차이를 full DFT에서
assertEqual(result.workload.positiveDifferenceBytes, 64, "raw bin bytes");
assertEqual(result.workload.outputBytes, 128, "total returned typed-array bytes");
assertEqual(result.workload.maximumOutputBytes, 128, "caller output limit");
+ const preflight = estimateDixonSpectralFluxWorkload({
+ sampleCount: 8,
+ fftSize: 4,
+ hopSize: 4,
+ maximumOutputBytes: 128,
+ });
+ assertEqual(preflight.frameCount, result.frameCount, "preflight frame count");
+ assertEqual(preflight.outputBytes, result.workload.outputBytes, "preflight output bytes");
+ assertEqual(
+ preflight.estimatedPeakTypedArrayBytes,
+ result.workload.estimatedPeakTypedArrayBytes,
+ "preflight peak typed-array bytes",
+ );
});
test("Dixon flux는 silence와 incomplete tail을 패딩 없이 처리한다", () => {