feat: add built-in array comparison
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user