feat: add built-in array comparison
This commit is contained in:
@@ -59,7 +59,7 @@
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
</section>
|
||||
@@ -67,6 +67,7 @@
|
||||
<aside class="control-panel" aria-label="Simulation parameters">
|
||||
<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>
|
||||
<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>
|
||||
<p id="array-meta">배열을 불러오고 있습니다.</p>
|
||||
</section>
|
||||
|
||||
@@ -23,6 +23,10 @@ const DEFAULT_DOCUMENT_URLS = {
|
||||
scene: "/assets/sample-scenes/single-tone.scene.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 MAX_HISTORY_ITEMS = 24;
|
||||
|
||||
@@ -51,6 +55,7 @@ const elements = {
|
||||
historyList: byId("history-list"),
|
||||
arrayTitle: byId("array-title"),
|
||||
microphoneCount: byId("microphone-count"),
|
||||
arrayPreset: byId("array-preset"),
|
||||
arrayCanvas: byId("array-canvas"),
|
||||
arrayMeta: byId("array-meta"),
|
||||
sourceSummary: byId("source-summary"),
|
||||
@@ -223,6 +228,9 @@ function syncControls() {
|
||||
elements.hopSize.value = experiment.stft.hopSize;
|
||||
elements.normalization.value = experiment.beamforming.normalization;
|
||||
elements.dynamicRange.value = experiment.beamforming.dynamicRangeDb;
|
||||
elements.arrayPreset.value = ARRAY_PRESET_URLS.has(state.arrayDefinition.id)
|
||||
? state.arrayDefinition.id
|
||||
: "custom";
|
||||
elements.gridPreset.value =
|
||||
experiment.scan.columns === experiment.scan.rows && [50, 100, 200].includes(experiment.scan.rows)
|
||||
? String(experiment.scan.rows)
|
||||
@@ -855,6 +863,34 @@ async function loadJson(url) {
|
||||
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) {
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
@@ -1039,6 +1075,7 @@ function installActionListeners() {
|
||||
"change",
|
||||
() => importDocument("experiment", elements.experimentInput),
|
||||
);
|
||||
elements.arrayPreset.addEventListener("change", () => loadArrayPreset(elements.arrayPreset.value));
|
||||
elements.assetInput.addEventListener("change", async () => {
|
||||
const files = [...(elements.assetInput.files ?? [])];
|
||||
elements.assetInput.value = "";
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
.array-heading { align-items: flex-start; }
|
||||
.array-heading h2 { margin: 0; font-size: .95rem; }
|
||||
.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-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; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,3 +39,45 @@ $arrayDefinition = [ordered]@{
|
||||
$json = $arrayDefinition | ConvertTo-Json -Depth 8
|
||||
[System.IO.File]::WriteAllText($outputPath, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false))
|
||||
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,
|
||||
"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();
|
||||
await waitFor(
|
||||
@@ -138,6 +149,9 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
);
|
||||
assert(!documentObject.querySelector("#result-badge").textContent.includes("이전 설정"), "fresh result badge");
|
||||
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 {
|
||||
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||||
windowObject.URL.revokeObjectURL = originalRevoke;
|
||||
|
||||
@@ -18,6 +18,21 @@ test("192채널 기준 배열이 계약을 통과한다", async () => {
|
||||
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 () => {
|
||||
const [arrayDefinition, scene, experiment] = await Promise.all([
|
||||
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
|
||||
|
||||
Reference in New Issue
Block a user