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 @@
+
+ 전 대역 활동 검사위치추정과 분리 · ADC spectrum +
+

Scene은 물리 마이크 신호 합성에만 사용하며 음원 위치·주파수·개수 정답은 검출기에 전달하지 않습니다. 위치추정과 독립적으로 ADC의 0…Fs/2만 관측하므로 analog alias order는 사용자가 지정해야 합니다.

+
+ + +
+ +
+ + + + +
+ + + +

OS/CA의 Pfa는 IID exponential 배경 모델에서 tested CUT 하나당 정한 nominal false-alarm 값입니다. 위치 확률·신뢰도·실측 보정값이 아닙니다.

+

Spectral flux는 onset novelty이며 지속 activity가 아닙니다. δ는 사용자가 데이터에 맞춰 정하는 보정값입니다.

+ +
+ + +
+

검사할 입력과 기준 마이크를 준비하세요.

+ +
+

검사 후 선택 frame의 원시 검출 통계와 candidate band를 표시합니다.

+
+
+
+
고급 설정음향 · 범위 · 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을 패딩 없이 처리한다", () => {