feat: add independent full-band activity workflow

This commit is contained in:
2026-08-29 13:56:09 +09:00
parent 3ebc0f84e0
commit 51e346a477
8 changed files with 2176 additions and 50 deletions
+576
View File
@@ -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();
}
});