feat: add built-in array comparison

This commit is contained in:
Codex
2026-08-28 12:19:42 +09:00
parent d04a4d9f65
commit 2ce96064cf
7 changed files with 1670 additions and 1 deletions
+2 -1
View File
@@ -59,7 +59,7 @@
<section class="history-section"> <section class="history-section">
<div class="section-heading"><div><p class="eyebrow">Same-scene comparison</p><h2>배열 비교 기록</h2></div><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div> <div class="section-heading"><div><p class="eyebrow">Same-scene comparison</p><h2>배열 비교 기록</h2></div><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div>
<p class="section-copy">Array JSON을 바꿔 다시 실행하면 같은 scene의 결과를 이 세션에서 나란히 비교합니다.</p> <p class="section-copy">비교 배열 또는 Array JSON을 바꿔 다시 실행하면 같은 scene·scan·STFT 결과를 이 세션에서 나란히 비교합니다.</p>
<div class="history-list" id="history-list"></div> <div class="history-list" id="history-list"></div>
</section> </section>
</section> </section>
@@ -67,6 +67,7 @@
<aside class="control-panel" aria-label="Simulation parameters"> <aside class="control-panel" aria-label="Simulation parameters">
<section class="array-card"> <section class="array-card">
<div class="array-heading"><div><p class="eyebrow">Microphone geometry</p><h2 id="array-title">배열</h2></div><strong id="microphone-count">— ch</strong></div> <div class="array-heading"><div><p class="eyebrow">Microphone geometry</p><h2 id="array-title">배열</h2></div><strong id="microphone-count">— ch</strong></div>
<label class="array-preset-field"><span>비교 배열</span><select id="array-preset"><option value="rectangular-16x12">16×12 rectangular · 기준</option><option value="fermat-spiral-192">Fermat spiral · 비교</option><option value="custom">불러온 Array JSON</option></select></label>
<canvas id="array-canvas" aria-label="마이크 배열 평면도"></canvas> <canvas id="array-canvas" aria-label="마이크 배열 평면도"></canvas>
<p id="array-meta">배열을 불러오고 있습니다.</p> <p id="array-meta">배열을 불러오고 있습니다.</p>
</section> </section>
+37
View File
@@ -23,6 +23,10 @@ const DEFAULT_DOCUMENT_URLS = {
scene: "/assets/sample-scenes/single-tone.scene.json", scene: "/assets/sample-scenes/single-tone.scene.json",
experiment: "/assets/sample-scenes/single-tone.experiment.json", experiment: "/assets/sample-scenes/single-tone.experiment.json",
}; };
const ARRAY_PRESET_URLS = new Map([
["rectangular-16x12", "/assets/array-geometries/rectangular-16x12.array.json"],
["fermat-spiral-192", "/assets/array-geometries/fermat-spiral-192.array.json"],
]);
const BAND_COLORS = ["#65f2bf", "#58c7ff", "#ff7f8f", "#ffc56e", "#b294ff", "#8fe388", "#ff9ee5", "#83a8ff"]; const BAND_COLORS = ["#65f2bf", "#58c7ff", "#ff7f8f", "#ffc56e", "#b294ff", "#8fe388", "#ff9ee5", "#83a8ff"];
const MAX_HISTORY_ITEMS = 24; const MAX_HISTORY_ITEMS = 24;
@@ -51,6 +55,7 @@ const elements = {
historyList: byId("history-list"), historyList: byId("history-list"),
arrayTitle: byId("array-title"), arrayTitle: byId("array-title"),
microphoneCount: byId("microphone-count"), microphoneCount: byId("microphone-count"),
arrayPreset: byId("array-preset"),
arrayCanvas: byId("array-canvas"), arrayCanvas: byId("array-canvas"),
arrayMeta: byId("array-meta"), arrayMeta: byId("array-meta"),
sourceSummary: byId("source-summary"), sourceSummary: byId("source-summary"),
@@ -223,6 +228,9 @@ function syncControls() {
elements.hopSize.value = experiment.stft.hopSize; elements.hopSize.value = experiment.stft.hopSize;
elements.normalization.value = experiment.beamforming.normalization; elements.normalization.value = experiment.beamforming.normalization;
elements.dynamicRange.value = experiment.beamforming.dynamicRangeDb; elements.dynamicRange.value = experiment.beamforming.dynamicRangeDb;
elements.arrayPreset.value = ARRAY_PRESET_URLS.has(state.arrayDefinition.id)
? state.arrayDefinition.id
: "custom";
elements.gridPreset.value = elements.gridPreset.value =
experiment.scan.columns === experiment.scan.rows && [50, 100, 200].includes(experiment.scan.rows) experiment.scan.columns === experiment.scan.rows && [50, 100, 200].includes(experiment.scan.rows)
? String(experiment.scan.rows) ? String(experiment.scan.rows)
@@ -855,6 +863,34 @@ async function loadJson(url) {
return response.json(); return response.json();
} }
async function loadArrayPreset(presetId) {
const url = ARRAY_PRESET_URLS.get(presetId);
if (!url) {
elements.arrayInput.click();
syncControls();
return;
}
const generation = ++state.importGenerations.array;
beginInputOperation();
try {
setRunStatus(`${presetId} 비교 배열을 불러오고 있습니다…`);
const value = await loadJson(url);
if (generation !== state.importGenerations.array) return;
const issues = await validateArrayDefinition(value);
if (generation !== state.importGenerations.array) return;
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
state.arrayDefinition = value;
state.experiment.arrayId = value.id;
markChanged({ syncControls: true });
setRunStatus(`${value.name} 배열을 선택했습니다. 실행하면 같은 설정 기록에 추가됩니다.`);
} catch (error) {
syncControls();
setTransientError(error, "비교 배열 불러오기 실패");
} finally {
endInputOperation();
}
}
async function importDocument(kind, input) { async function importDocument(kind, input) {
const file = input.files?.[0]; const file = input.files?.[0];
input.value = ""; input.value = "";
@@ -1039,6 +1075,7 @@ function installActionListeners() {
"change", "change",
() => importDocument("experiment", elements.experimentInput), () => importDocument("experiment", elements.experimentInput),
); );
elements.arrayPreset.addEventListener("change", () => loadArrayPreset(elements.arrayPreset.value));
elements.assetInput.addEventListener("change", async () => { elements.assetInput.addEventListener("change", async () => {
const files = [...(elements.assetInput.files ?? [])]; const files = [...(elements.assetInput.files ?? [])];
elements.assetInput.value = ""; elements.assetInput.value = "";
+4
View File
@@ -55,6 +55,10 @@
.array-heading { align-items: flex-start; } .array-heading { align-items: flex-start; }
.array-heading h2 { margin: 0; font-size: .95rem; } .array-heading h2 { margin: 0; font-size: .95rem; }
.array-heading > strong { color: var(--mint); font: 750 .78rem ui-monospace, SFMono-Regular, Consolas, monospace; } .array-heading > strong { color: var(--mint); font: 750 .78rem ui-monospace, SFMono-Regular, Consolas, monospace; }
.array-preset-field { display: grid; gap: 6px; margin-top: 12px; }
.array-preset-field span { color: var(--muted); font-size: .66rem; font-weight: 650; }
.array-preset-field select { width: 100%; min-height: 37px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; }
.array-preset-field select:focus { border-color: var(--mint); outline: 2px solid rgb(101 242 191 / 11%); }
#array-canvas { display: block; width: 100%; height: 265px; margin-top: 10px; border: 1px solid var(--line); border-radius: 12px; background: #07110f; } #array-canvas { display: block; width: 100%; height: 265px; margin-top: 10px; border: 1px solid var(--line); border-radius: 12px; background: #07110f; }
#array-meta { margin: 9px 0 0; color: var(--muted); font: 600 .66rem ui-monospace, SFMono-Regular, Consolas, monospace; } #array-meta { margin: 9px 0 0; color: var(--muted); font: 600 .66rem ui-monospace, SFMono-Regular, Consolas, monospace; }
.section-title { display: flex; align-items: center; gap: 9px; margin-bottom: 13px; } .section-title { display: flex; align-items: center; gap: 9px; margin-bottom: 13px; }
File diff suppressed because it is too large Load Diff
+42
View File
@@ -39,3 +39,45 @@ $arrayDefinition = [ordered]@{
$json = $arrayDefinition | ConvertTo-Json -Depth 8 $json = $arrayDefinition | ConvertTo-Json -Depth 8
[System.IO.File]::WriteAllText($outputPath, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($outputPath, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false))
Write-Host "Generated $($microphones.Count) microphones: $outputPath" Write-Host "Generated $($microphones.Count) microphones: $outputPath"
$spiralOutputPath = Join-Path $projectRoot 'assets/array-geometries/fermat-spiral-192.array.json'
$spiralMicrophones = [System.Collections.Generic.List[object]]::new()
$microphoneCount = 192
$radius = 0.225
$goldenAngle = [Math]::PI * (3 - [Math]::Sqrt(5))
for ($index = 0; $index -lt $microphoneCount; $index++) {
$microphoneRadius = $radius * [Math]::Sqrt(($index + 0.5) / $microphoneCount)
$angle = $index * $goldenAngle
$x = $microphoneRadius * [Math]::Cos($angle)
$y = $microphoneRadius * [Math]::Sin($angle)
$spiralMicrophones.Add([ordered]@{
id = 'mic-{0:D3}' -f ($index + 1)
position = @([Math]::Round($x, 6), [Math]::Round($y, 6), 0)
})
}
$spiralDefinition = [ordered]@{
schemaVersion = '1.0.0'
kind = 'micarrsim.array'
id = 'fermat-spiral-192'
name = '192-channel Fermat spiral'
description = 'Equal-area Fermat spiral on a 0.45 m circular aperture for comparison with the rectangular baseline'
units = 'm'
coordinateSystem = [ordered]@{
handedness = 'right'
xAxis = 'right'
yAxis = 'up'
zAxis = 'toward-scene'
}
aperture = [ordered]@{ width = 0.45; height = 0.45 }
microphones = $spiralMicrophones
}
$spiralJson = $spiralDefinition | ConvertTo-Json -Depth 8
[System.IO.File]::WriteAllText(
$spiralOutputPath,
$spiralJson + [Environment]::NewLine,
[System.Text.UTF8Encoding]::new($false)
)
Write-Host "Generated $($spiralMicrophones.Count) microphones: $spiralOutputPath"
+14
View File
@@ -124,6 +124,17 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
!documentObject.querySelector("#run-button").disabled, !documentObject.querySelector("#run-button").disabled,
"original comparison configuration restore", "original comparison configuration restore",
); );
dispatchValue(
windowObject,
documentObject.querySelector("#array-preset"),
"fermat-spiral-192",
);
await waitFor(
() => documentObject.querySelector("#array-title").textContent.includes("Fermat") &&
documentObject.documentElement.dataset.cameraValid === "true" &&
!documentObject.querySelector("#run-button").disabled,
"Fermat comparison array",
);
documentObject.querySelector("#run-button").click(); documentObject.querySelector("#run-button").click();
await waitFor( await waitFor(
@@ -138,6 +149,9 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
); );
assert(!documentObject.querySelector("#result-badge").textContent.includes("이전 설정"), "fresh result badge"); assert(!documentObject.querySelector("#result-badge").textContent.includes("이전 설정"), "fresh result badge");
assertEqual(documentObject.querySelectorAll("#history-list .history-card").length, 2, "rerun history"); assertEqual(documentObject.querySelectorAll("#history-list .history-card").length, 2, "rerun history");
const historyText = documentObject.querySelector("#history-list").textContent;
assert(historyText.includes("rectangular baseline"), "baseline history retained");
assert(historyText.includes("Fermat spiral"), "comparison array history added");
} finally { } finally {
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick; windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
windowObject.URL.revokeObjectURL = originalRevoke; windowObject.URL.revokeObjectURL = originalRevoke;
+15
View File
@@ -18,6 +18,21 @@ test("192채널 기준 배열이 계약을 통과한다", async () => {
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "array issues"); assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "array issues");
}); });
test("192채널 Fermat spiral 비교 배열이 계약과 원형 aperture를 만족한다", async () => {
const arrayDefinition = await loadJson("/assets/array-geometries/fermat-spiral-192.array.json");
assertEqual(arrayDefinition.microphones.length, 192, "spiral microphone count");
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "spiral array issues");
const maximumRadius = Math.max(
...arrayDefinition.microphones.map(({ position }) => Math.hypot(position[0], position[1])),
);
assert(maximumRadius <= 0.225001, `spiral aperture radius: ${maximumRadius}`);
assertEqual(
new Set(arrayDefinition.microphones.map(({ position }) => position.join(","))).size,
192,
"spiral positions are unique",
);
});
test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () => { test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([ const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"), loadJson("/assets/array-geometries/rectangular-16x12.array.json"),