Compare commits

...

12 Commits

Author SHA1 Message Date
Codex fd3d98947a docs: guide audio localization and live playback 2026-08-28 16:36:37 +09:00
Codex 7cd737429a feat: tune multi-source peak sensitivity 2026-08-28 16:30:01 +09:00
Codex a54aa25cd9 feat: play moving-source acoustic timelines 2026-08-28 16:23:26 +09:00
Codex 01a37eac69 feat: analyze moving sources frame by frame 2026-08-28 16:11:16 +09:00
Codex 6490bbe6fc fix: keep camera geometry aligned with trajectories 2026-08-28 16:08:21 +09:00
Codex 6280fa4dfd feat: synthesize trajectory analysis windows 2026-08-28 16:05:22 +09:00
Codex a20b55e8e2 feat: edit moving source trajectories 2026-08-28 16:04:55 +09:00
Codex acb36bf15d feat: plan bounded live timelines 2026-08-28 16:02:03 +09:00
Codex a7befa0270 feat: define moving source trajectories 2026-08-28 15:48:16 +09:00
Codex b678d1f9c7 feat: visualize beam spread around peaks 2026-08-28 15:48:02 +09:00
Codex 95f2ca19a6 test: add one-command browser runner 2026-08-28 15:47:47 +09:00
Codex 9a7401c023 feat: streamline arbitrary audio sources 2026-08-28 15:43:57 +09:00
46 changed files with 3281 additions and 129 deletions
+49 -5
View File
@@ -1,8 +1,9 @@
# micarrsim
192채널 MEMS 마이크 배열의 배치와 자유음장 DAS 음향 영상을 비교하는 로컬 브라우저
시뮬레이터입니다. Dataset Builder에서 재현 가능한 음향 장면을 만들고 Acoustic Camera에서
CPU 또는 WebGPU로 배열 응답, acoustic map과 위치 검출 지표를 계산합니다.
시뮬레이터입니다. Dataset Builder에서 녹음 파일·합성 신호와 이동 경로를 포함한 재현 가능한
음향 장면을 만들고 Acoustic Camera에서 CPU 또는 WebGPU로 배열 응답, acoustic map과 위치 검출
지표를 계산하거나 이동 장면을 시간축으로 재생합니다.
별도 프로그램이나 패키지를 설치하지 않습니다. 모든 앱과 계산은 이 저장소와 브라우저 안에서
실행됩니다.
@@ -51,15 +52,26 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
실제 완료 시간은 브라우저와 장치에 따라 다릅니다. CPU fallback에서는 WebGPU보다 오래 걸릴 수
있습니다.
### 이동 음원 바로 보기
1. Acoustic Camera에서 **이동 예제**를 누릅니다.
2. **라이브 준비**를 눌러 2 Hz acoustic map을 계산합니다.
3. **재생**을 누르거나 시간 slider를 움직여 음원과 heatmap의 이동을 확인합니다.
이 기능은 trajectory가 있는 합성 Scene의 준정적 시간창 재생입니다. 실제 192채널 마이크 장치에서
들어오는 스트림을 캡처하는 기능은 아닙니다.
## 내 장면으로 전체 흐름 실행하기
### 1. Dataset Builder에서 Scene 만들기
1. 시작 화면에서 **Dataset Builder**를 엽니다.
2. 선택적으로 배경 이미지와 오디오를 추가합니다.
3. 음원을 추가해 canvas 또는 x/y/z 입력으로 위치를 정하고, tone·noise·audio asset과 ambient를
설정합니다.
4. 상태가 **저장 가능한 장면**이면 **Scene JSON 저장**을 누릅니다.
3. 녹음 파일은 **+ 오디오 파일 음원** 한 번으로 asset과 source를 함께 만들 수 있습니다. 합성
음원은 별도 버튼으로 추가합니다.
4. canvas 또는 x/y/z 입력으로 위치를 정하고 tone·noise·audio asset과 ambient를 설정합니다.
5. 움직이는 음원은 **시간에 따라 음원 이동**을 켜고 keyframe과 경로 미리보기 시간을 편집합니다.
6. 상태가 **저장 가능한 장면**이면 **Scene JSON 저장**을 누릅니다.
**Preview WAV 저장**은 source와 ambient를 공간 정보 없이 하나의 mono channel로 합친 청취
확인용 파일입니다. 192채널 마이크 신호나 beamforming 결과가 아닙니다.
@@ -106,6 +118,31 @@ WebGPU 경로도 신호 합성과 STFT 일부는 CPU에서 수행합니다. grid
100×100은 4배, 200×200은 16배입니다. 처음에는 50×50을 사용하고 해상도가 필요할 때만 높이세요.
실행 시간은 microphone 수, scene 길이, FFT, 대역 폭과 장치에 함께 좌우됩니다.
## 위치 추정, 빔 분포와 복수 음원
DAS 출력은 각 scan point의 상대 acoustic power map이며 위치 확률분포가 아닙니다. Camera는 map의
local peak를 위치 후보로 표시합니다. **빔 분포**를 켜면 각 peak의 연결된 half-power lobe에 대해
power 가중 공분산을 계산해 1× RMS 점선과 2× RMS 영역을 그립니다. Gaussian의 등고선처럼 보일 수
있지만 통계적으로 보정된 Gaussian, 신뢰구간 또는 발생 확률은 아닙니다.
복수 음원은 각각 별도 local peak가 남을 만큼 공간적으로 분리되고 선택 대역에 에너지가 있어야
검출됩니다. **Peak 검출 임계값 (%)**은 최강 peak 대비 후보의 최소 power입니다. 기본 25%에서 약한
두 번째 음원이 빠지면 10–20%로 낮출 수 있지만 sidelobe 오검출도 늘어납니다. 서로 같은 대역에서
main lobe보다 가깝거나 gain 차이가 큰 음원은 conventional DAS의 물리·알고리즘 한계 때문에
threshold만으로 분리되지 않을 수 있습니다.
## 이동 음원 라이브의 동작
Builder의 trajectory는 Scene 시작 기준 절대 시간의 keyframe 사이를 직선 보간합니다. Camera는
장면을 sample 경계에 맞춘 짧은 시간창으로 나누고, 각 창 중앙의 음원 위치에서 tone 위상, noise
index와 audio asset 재생 위치가 이어지도록 신호를 합성합니다. 계산된 map 사이에서는 heatmap을
보간하고 ground-truth 위치는 연속 보간합니다.
- Map rate: 초당 분석 map 목표 수. 높을수록 움직임이 세밀하지만 준비 시간이 늘 수 있습니다.
- 라이브 미리보기 상한: 48 maps, map당 최대 2,500 scan points
- 모델: 준정적 위치 변화이며 Doppler와 창 내부의 연속 전파 경로 변화는 포함하지 않음
- 실제 장치: 물리 192채널 capture/stream gateway는 하드웨어 형식이 정해진 뒤 별도 adapter가 필요
## Result JSON
Result JSON은 계약 버전 `1.0.0`의 `micarrsim.result` 문서입니다. 주요 내용은 다음과 같습니다.
@@ -117,6 +154,7 @@ Result JSON은 계약 버전 `1.0.0`의 `micarrsim.result` 문서입니다. 주
- 주파수 대역별 선형 power `rawMap`, 표시용 `displayMap`, 검출 `peaks`
- 위치 오차, precision/recall, truth 반경 내 energy, peak-to-sidelobe, main-lobe 폭을 계산하기
위한 평가 파라미터와 `metrics`
- peak별 half-power 공분산과 RMS ellipse를 담은 `beamSpread` (`calibrated: false`)
평가에서 tone source는 tone 주파수가 포함되는 band의 ground truth로만 사용합니다. Audio asset과
noise source는 스펙트럼을 미리 알 수 없으므로 모든 band의 ground truth로 취급합니다. 이 가정은
@@ -143,6 +181,12 @@ Camera의 **비교 배열** 선택으로 두 fixture를 같은 scene과 experime
## 테스트와 benchmark
최상위 폴더에서 자동 서버·headless 브라우저·정리까지 한 번에 실행하려면 다음을 사용합니다.
```bat
test.cmd
```
서버를 실행한 뒤 다음 주소를 브라우저에서 엽니다.
- 전체 browser test runner: <http://127.0.0.1:4173/tests/>
+28
View File
@@ -15,6 +15,9 @@ Scene과 마이크 배열로 자유음장 microphone signal을 합성하고, STF
3. **기본 예제 실행**을 누릅니다.
4. 완료 후 overlay, 위치 오차와 timing을 확인하고 **Result JSON**을 저장합니다.
이동 장면을 바로 보려면 **이동 예제 → 라이브 준비 → 재생** 순서로 누릅니다. 기본 예제는
24×18 grid와 2 Hz map rate를 사용해 CPU fallback에서도 준비량을 줄였습니다.
## 사용자 입력 불러오기
문서는 다음 순서로 불러오는 것이 안전합니다.
@@ -58,10 +61,18 @@ WebGPU가 감지돼도 실제 장치는 실행 시 확인합니다. 계산 중 *
실행 전에 표시됩니다. 현재 후처리는 raw DAS만 지원하며 DAMAS와 CLEAN-SC 항목은 비활성화되어
있습니다.
**Peak 검출 임계값 (%)**은 최강 local peak의 power에 대한 상대 임계값이며 Experiment JSON에
저장됩니다. 기본 25%에서 약한 두 번째 음원이 빠지면 10–20%로 낮추세요. 낮은 값은 더 약한 후보를
살리는 대신 sidelobe를 음원으로 잘못 고를 가능성도 높입니다. 같은 대역의 두 음원이 main lobe
폭보다 가깝다면 grid만 키우거나 threshold를 낮춰도 conventional DAS map에서 하나로 합쳐질 수
있습니다.
## 화면과 배열 비교
- acoustic overlay의 투명도와 표시할 band를 선택할 수 있습니다.
- **Ground truth**를 켜면 source 위치와 검출 peak를 비교할 수 있습니다.
- **빔 분포**는 peak 주변 half-power lobe의 power 가중 공분산을 1× RMS 점선과 2× RMS 영역으로
표시합니다. 위치 확률, Gaussian 신뢰구간 또는 측정 불확실성 보정값은 아닙니다.
- rectangular와 Fermat spiral preset을 같은 Scene/Experiment에서 연속 실행하면 **배열 비교 기록**에
backend, timing과 평가 요약이 남습니다.
- 입력을 바꾼 뒤 화면에 남은 결과는 이전 실행 snapshot이며 UI가 이전 설정 결과임을 표시합니다.
@@ -74,6 +85,22 @@ WebGPU가 감지돼도 실제 장치는 실행 시 확인합니다. 계산 중 *
않고 새 실행을 시작하면 교체 여부를 확인하며, 미저장 항목이나 실행 중 계산이 있는 상태에서
페이지를 닫으면 브라우저가 한 번 더 확인합니다.
## 이동 음원 라이브
Scene source에 두 개 이상의 `trajectory` keyframe이 있으면 **라이브 준비**가 활성화됩니다.
Builder에서는 **시간에 따라 음원 이동**과 경로 미리보기 slider로 이 데이터를 만들 수 있습니다.
1. Map rate와 backend를 선택합니다. 처음에는 2 Hz와 Auto를 권장합니다.
2. **라이브 준비**를 누릅니다. 현재 입력 snapshot을 최대 48개, map당 최대 2,500 points의 짧은
분석창으로 계산합니다.
3. **재생** 또는 시간 slider로 이동합니다. Heatmap은 이웃한 acoustic map 사이를 보간하고
source 위치는 keyframe 사이를 연속 보간합니다.
각 시간창은 sample 경계에 맞추며 tone 위상, 결정적 noise index와 audio asset의 재생 위치를 원래
Scene 시간에 이어 붙입니다. 한 창 안에서는 중앙 시각의 위치를 고정하는 준정적 근사이므로 Doppler,
창 내부 연속 경로 변화와 실제 마이크 스트림은 지원하지 않습니다. 실제 192채널 장치를 연결하려면
장치의 channel 순서, sample 형식, clock와 전송 API에 맞춘 capture adapter가 별도로 필요합니다.
## Result JSON
**Result JSON**은 검증을 통과한 `micarrsim.result` 문서만 저장합니다.
@@ -83,6 +110,7 @@ WebGPU가 감지돼도 실제 장치는 실행 시 확인합니다. 계산 중 *
- `signalStats`: 정규화 전 peak, 적용 scale, clipped sample 수
- `timingMs`: 전체, signal generation과 beamforming 시간
- band별 `rawMap` 선형 power, `displayMap`, 검출 `peaks`
- 각 peak의 비보정 half-power 공분산과 RMS ellipse인 `beamSpread`
- 평가 설정과 `peakToSidelobeDb`, `energyInsideTruthRatio`, `precision`, `recall`,
`mainLobeWidthM`
+23
View File
@@ -51,9 +51,31 @@
<div class="overlay-toolbar">
<div class="band-legend" id="band-legend" aria-label="표시 주파수 대역"></div>
<label class="range-field"><span>Overlay</span><input id="overlay-opacity" type="range" min="0" max="1" step="0.05" value="0.72" /></label>
<label class="check-field" title="점선은 1× RMS, 실선 영역은 2× RMS 빔 분포입니다. 확률 또는 신뢰구간은 아닙니다."><input id="show-beam-spread" type="checkbox" checked /><span>빔 분포</span></label>
<label class="check-field"><input id="show-truth" type="checkbox" checked /><span>Ground truth</span></label>
</div>
<section class="live-panel" aria-labelledby="live-title">
<div class="live-heading">
<div>
<p class="eyebrow">Moving-source timeline</p>
<h2 id="live-title">이동 음원 라이브</h2>
<p id="live-status" aria-live="polite">trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.</p>
</div>
<div class="live-actions">
<button class="button button--ghost" id="load-moving-example-button" type="button">이동 예제</button>
<button class="button button--primary" id="prepare-live-button" type="button" disabled>라이브 준비</button>
<button class="button button--ghost" id="live-play-button" type="button" disabled>▶ 재생</button>
</div>
</div>
<div class="live-controls">
<label class="live-scrubber"><span id="live-time-label">0.00 / 0.00 s</span><input id="live-time" type="range" min="0" max="0" step="0.001" value="0" disabled /></label>
<label class="compact-select"><span>Map rate</span><select id="live-map-rate"><option value="2" selected>2 Hz</option><option value="5">5 Hz</option><option value="8">8 Hz</option></select></label>
<label class="compact-select"><span>속도</span><select id="live-speed"><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option></select></label>
</div>
<p class="live-note">짧은 분석창의 중앙 위치를 사용하는 준정적 미리보기입니다. Heatmap은 계산된 frame 사이를 보간하고 위치 표시는 연속 이동합니다. 시뮬레이션 장면 재생이며 실제 마이크 스트림 입력은 아닙니다.</p>
</section>
<section class="result-section" aria-live="polite">
<div class="section-heading"><div><p class="eyebrow">Evaluation</p><h2>실행 결과</h2></div><span class="result-chip" id="result-badge">아직 실행하지 않음</span></div>
<div class="empty-result" id="empty-result">시뮬레이션을 실행하면 위치 오차, energy concentration, sidelobe와 timing을 표시합니다.</div>
@@ -115,6 +137,7 @@
<label class="field"><span>Hop size</span><input id="hop-size" type="number" min="1" max="4096" step="1" /></label>
<label class="field"><span>Map 정규화</span><select id="normalization"><option value="peak">Peak</option><option value="none">없음</option></select></label>
<label class="field"><span>Dynamic range (dB)</span><input id="dynamic-range" type="number" min="6" max="120" step="1" /></label>
<label class="field" title="최강 peak 대비 검출 임계값입니다. 낮추면 약한 음원을 더 찾지만 sidelobe 오검출도 늘 수 있습니다."><span>Peak 검출 임계값 (%)</span><input id="detection-threshold" type="number" min="1" max="100" step="1" /></label>
<label class="field field--wide"><span>후처리</span><select id="postprocess"><option value="none">없음 · raw DAS</option><option disabled>DAMAS · 후속 milestone</option><option disabled>CLEAN-SC · 후속 milestone</option></select></label>
</div>
</section>
+494 -9
View File
@@ -7,6 +7,12 @@ import {
validateScene,
} from "/packages/contracts/validation.js";
import { createCameraAssetRuntime } from "./asset-runtime.js";
import {
createAnalysisFramePlan,
playbackFrameAtTime,
sceneHasTrajectories,
scenePlaybackState,
} from "./timeline.js";
import {
alignExperimentToScene,
canonicalStringify,
@@ -24,12 +30,20 @@ const DEFAULT_DOCUMENT_URLS = {
scene: "/assets/sample-scenes/single-tone.scene.json",
experiment: "/assets/sample-scenes/single-tone.experiment.json",
};
const MOVING_EXAMPLE_URLS = {
arrayDefinition: "/assets/array-geometries/rectangular-16x12.array.json",
scene: "/assets/sample-scenes/moving-tone.scene.json",
experiment: "/assets/sample-scenes/moving-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;
const MAX_LIVE_ANALYSIS_FRAMES = 48;
const MAX_LIVE_SCAN_POINTS = 2500;
const DEFAULT_DETECTION_THRESHOLD_RATIO = 0.25;
const byId = (id) => document.getElementById(id);
const elements = {
@@ -48,7 +62,17 @@ const elements = {
canvasBadge: byId("canvas-badge"),
bandLegend: byId("band-legend"),
overlayOpacity: byId("overlay-opacity"),
showBeamSpread: byId("show-beam-spread"),
showTruth: byId("show-truth"),
livePanel: document.querySelector(".live-panel"),
liveStatus: byId("live-status"),
loadMovingExampleButton: byId("load-moving-example-button"),
prepareLiveButton: byId("prepare-live-button"),
livePlayButton: byId("live-play-button"),
liveTime: byId("live-time"),
liveTimeLabel: byId("live-time-label"),
liveMapRate: byId("live-map-rate"),
liveSpeed: byId("live-speed"),
resultBadge: byId("result-badge"),
emptyResult: byId("empty-result"),
metricGrid: byId("metric-grid"),
@@ -79,6 +103,7 @@ const elements = {
hopSize: byId("hop-size"),
normalization: byId("normalization"),
dynamicRange: byId("dynamic-range"),
detectionThreshold: byId("detection-threshold"),
postprocess: byId("postprocess"),
addBandButton: byId("add-band-button"),
bandList: byId("band-list"),
@@ -100,6 +125,8 @@ const state = {
validation: { valid: false, issues: [] },
visibleBandIds: new Set(),
lastRun: null,
live: null,
liveError: null,
history: [],
active: null,
pendingInputOperations: 0,
@@ -143,6 +170,14 @@ function comparisonSignature(scene = state.scene, sourceExperiment = state.exper
return canonicalStringify({ scene, experiment });
}
function applyExperimentDefaults(experiment) {
if (!experiment.evaluation) experiment.evaluation = {};
if (experiment.evaluation.detectionThresholdRatio === undefined) {
experiment.evaluation.detectionThresholdRatio = DEFAULT_DETECTION_THRESHOLD_RATIO;
}
return experiment;
}
function setRunStatus(message) {
elements.runStatus.textContent = message;
}
@@ -179,11 +214,80 @@ function setTransientError(error, prefix = "오류") {
elements.validationMessage.textContent = message;
}
function stopLivePlayback(render = true) {
const live = state.live;
if (!live) return;
if (live.animationFrameId) cancelAnimationFrame(live.animationFrameId);
live.animationFrameId = 0;
live.playing = false;
live.playbackStartedAt = null;
if (render) renderLiveControls();
}
function resetLiveTimeline() {
stopLivePlayback(false);
state.live = null;
state.liveError = null;
renderLiveControls();
}
function renderLiveControls() {
const hasTrajectory = sceneHasTrajectories(state.scene);
const ready = Boolean(state.live?.frames?.length);
const preparing = state.active?.kind === "live";
const playing = Boolean(ready && state.live.playing);
const locked = Boolean(state.active) || state.pendingInputOperations > 0;
elements.loadMovingExampleButton.disabled = locked;
elements.prepareLiveButton.disabled = locked || !hasTrajectory || !state.validation.valid;
elements.livePlayButton.disabled = locked || !ready;
elements.liveTime.disabled = locked || !ready;
elements.liveMapRate.disabled = locked;
elements.liveSpeed.disabled = locked || !ready;
elements.livePlayButton.textContent = playing ? "❚❚ 일시정지" : "▶ 재생";
elements.livePanel.classList.toggle("is-ready", ready);
elements.livePanel.classList.toggle("is-playing", playing);
if (ready) {
const time = Math.min(state.live.plan.duration, Math.max(0, state.live.time));
elements.liveTime.max = String(state.live.plan.duration);
elements.liveTime.value = String(time);
elements.liveTimeLabel.textContent =
`${formatNumber(time, 2)} / ${formatNumber(state.live.plan.duration, 2)} s`;
elements.liveStatus.textContent =
`${state.live.frames.length}개 acoustic map · ${formatNumber(state.live.plan.effectiveMapHz, 1)} Hz · ` +
`${state.live.snapshot.experiment.scan.columns}×${state.live.snapshot.experiment.scan.rows}` +
`${state.live.reducedScan ? " preview" : ""} · ${state.live.backend.toUpperCase()}`;
} else {
elements.liveTime.max = String(state.scene?.duration ?? 0);
elements.liveTime.value = "0";
elements.liveTimeLabel.textContent = `0.00 / ${formatNumber(state.scene?.duration ?? 0, 2)} s`;
if (preparing) {
elements.liveStatus.textContent = "이동 음원의 시간창 acoustic map을 계산하고 있습니다…";
} else if (state.liveError) {
elements.liveStatus.textContent = `라이브 준비 실패 · ${state.liveError}`;
} else if (hasTrajectory) {
elements.liveStatus.textContent = "이동 경로를 찾았습니다. ‘라이브 준비’를 누르면 시간축 map을 계산합니다.";
} else {
elements.liveStatus.textContent = "trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.";
}
}
let liveState = "unavailable";
if (hasTrajectory) liveState = "idle";
if (state.liveError) liveState = "error";
if (ready) liveState = "ready";
if (playing) liveState = "playing";
if (preparing) liveState = "preparing";
document.documentElement.dataset.liveState = liveState;
document.documentElement.dataset.liveFrameCount = ready ? String(state.live.frames.length) : "0";
document.documentElement.dataset.liveTime = ready ? String(state.live.time) : "0";
}
function setConfigurationLocked(locked) {
setEditableControlsDisabled(locked);
elements.runButton.disabled = locked || !state.validation.valid;
elements.cancelButton.classList.toggle("is-hidden", !locked);
elements.exportResultButton.disabled = locked || !state.lastRun;
renderLiveControls();
}
function setEditableControlsDisabled(disabled) {
@@ -201,6 +305,7 @@ function beginInputOperation() {
document.querySelectorAll(".workspace-tools input").forEach((input) => {
input.disabled = true;
});
renderLiveControls();
}
function endInputOperation() {
@@ -215,6 +320,7 @@ function endInputOperation() {
input.disabled = false;
});
}
renderLiveControls();
scheduleValidation();
}
@@ -227,6 +333,7 @@ function markChanged(options = {}) {
if (dirty === "scene" || dirty === "both") state.sceneDirty = true;
if (dirty === "experiment" || dirty === "both") state.experimentDirty = true;
state.revision += 1;
resetLiveTimeline();
if (state.lastRun) state.lastRun.stale = true;
if (["error", "canceled"].includes(document.documentElement.dataset.runState)) {
document.documentElement.dataset.runState = "idle";
@@ -262,6 +369,9 @@ function syncControls() {
elements.hopSize.value = experiment.stft.hopSize;
elements.normalization.value = experiment.beamforming.normalization;
elements.dynamicRange.value = experiment.beamforming.dynamicRangeDb;
const detectionThresholdRatio =
experiment.evaluation?.detectionThresholdRatio ?? DEFAULT_DETECTION_THRESHOLD_RATIO;
elements.detectionThreshold.value = String(Number((detectionThresholdRatio * 100).toFixed(4)));
elements.arrayPreset.value = ARRAY_PRESET_URLS.has(state.arrayDefinition.id)
? state.arrayDefinition.id
: "custom";
@@ -298,7 +408,8 @@ function renderDocumentSummary() {
const detail = document.createElement("span");
detail.textContent =
`${describeSignal(source.signal)} · ` +
`(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m`;
`(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m` +
(source.trajectory?.length ? ` · 이동 ${source.trajectory.length} keyframes` : "");
line.append(name, detail);
elements.sourceSummary.append(line);
}
@@ -418,8 +529,60 @@ function bandsForVisualization(lastRun) {
}));
}
function interpolateMap(left, right, progress) {
if (left === right || !(progress > 0)) return left;
if (!left || !right || left.length !== right.length) return progress < 0.5 ? left : right;
const values = new Float32Array(left.length);
const inverse = 1 - progress;
for (let index = 0; index < values.length; index += 1) {
values[index] = left[index] * inverse + right[index] * progress;
}
return values;
}
function liveVisualizationState(live) {
const playback = scenePlaybackState(live.snapshot.scene, live.plan, live.time);
const leftFrame = live.frames[playback.leftFrameIndex];
const rightFrame = live.frames[playback.rightFrameIndex];
const nearestFrame = live.frames[playback.frameIndex];
const rightBands = new Map(rightFrame.run.bands.map((band) => [band.id, band]));
const definitions = new Map(
live.snapshot.experiment.frequencyBands.map((band) => [band.id, band]),
);
const bands = leftFrame.run.bands.map((leftBand) => {
const rightBand = rightBands.get(leftBand.id) ?? leftBand;
return {
...leftBand,
displayMap: interpolateMap(leftBand.displayMap, rightBand.displayMap, playback.progress),
color: leftBand.color ?? definitions.get(leftBand.id)?.color ?? "#65f2bf",
visible: state.visibleBandIds.has(leftBand.id),
};
});
return { playback, scene: playback.scene, bands, evaluations: nearestFrame.evaluations };
}
function renderSceneVisualization() {
if (!state.scene || !state.experiment) return;
if (state.live?.frames?.length) {
const live = state.live;
const visualization = liveVisualizationState(live);
sceneVisualization.render({
scene: visualization.scene,
experiment: live.snapshot.experiment,
image: live.imageElement,
bands: visualization.bands,
evaluations: visualization.evaluations,
dynamicRangeDb: live.snapshot.experiment.beamforming.dynamicRangeDb,
opacity: Number(elements.overlayOpacity.value),
showBeamSpread: elements.showBeamSpread.checked,
showTruth: elements.showTruth.checked,
});
elements.canvasMessage.classList.add("is-hidden");
elements.canvasBadge.textContent =
`LIVE · ${formatNumber(live.time, 2)} s · map ${visualization.playback.framePosition.toFixed(1)}/` +
`${Math.max(0, live.frames.length - 1)} · ${live.backend.toUpperCase()}`;
return;
}
const displayed = state.lastRun;
const snapshot = displayed?.snapshot ?? { scene: state.scene, experiment: state.experiment };
sceneVisualization.render({
@@ -430,6 +593,7 @@ function renderSceneVisualization() {
evaluations: displayed?.evaluations,
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
opacity: Number(elements.overlayOpacity.value),
showBeamSpread: elements.showBeamSpread.checked,
showTruth: elements.showTruth.checked,
});
elements.canvasMessage.classList.toggle("is-hidden", Boolean(displayed || assetRuntime.imageElement));
@@ -487,6 +651,14 @@ function renderResult() {
`${formatNumber(displayed.run.timingMs.beamforming, 0)} ms`,
`${displayed.snapshot.experiment.scan.columns}×${displayed.snapshot.experiment.scan.rows} grid`,
);
const detectionThreshold =
displayed.snapshot.experiment.evaluation?.detectionThresholdRatio ??
DEFAULT_DETECTION_THRESHOLD_RATIO;
appendMetric(
"Peak 검출 임계값",
`${formatNumber(detectionThreshold * 100, 0)}%`,
"최강 peak power 기준 · 낮을수록 민감",
);
for (const band of displayed.snapshot.experiment.frequencyBands) {
const evaluation = displayed.evaluations.get(band.id);
if (!evaluation) continue;
@@ -524,6 +696,16 @@ function renderResult() {
"half-power equivalent diameter",
band.color,
);
const spread = evaluation.detections?.find((detection) => detection.beamSpread)?.beamSpread;
if (spread?.ellipse) {
appendMetric(
`${band.id} · 빔 분포`,
`${formatNumber(spread.ellipse.majorRmsRadiusM * 100, 1)} × ` +
`${formatNumber(spread.ellipse.minorRmsRadiusM * 100, 1)} cm`,
`1× RMS · ${formatNumber(spread.ellipse.angleDeg, 0)}° · 확률 분포 아님`,
band.color,
);
}
}
}
@@ -563,8 +745,10 @@ function renderHistory() {
function validationWarnings() {
const warnings = [];
if (!state.scene || !state.experiment || !state.arrayDefinition) return warnings;
const zMismatch = state.scene.sources.some(
(source) => Math.abs(source.position[2] - state.experiment.scan.z) > 1e-6,
const zMismatch = state.scene.sources.some((source) =>
[source.position, ...(source.trajectory ?? []).map(({ position }) => position)].some(
(position) => Math.abs(position[2] - state.experiment.scan.z) > 1e-6,
),
);
if (zMismatch) {
warnings.push("일부 source z가 scan z와 달라 ground truth에는 평면 투영값을 사용합니다.");
@@ -667,6 +851,7 @@ function applyValidation(validation) {
}
}
}
renderLiveControls();
}
function scheduleValidation() {
@@ -678,8 +863,13 @@ function scheduleValidation() {
});
}
function sourcesForBand(scene, band) {
function sourcesForBand(scene, band, time = null) {
return scene.sources.filter((source) => {
if (time !== null) {
const startTime = source.startTime ?? 0;
const endTime = startTime + (source.duration ?? scene.duration);
if (time < startTime || time > endTime) return false;
}
if (source.signal.type !== "tone") return true;
return source.signal.frequency >= band.minHz && source.signal.frequency <= band.maxHz;
});
@@ -703,7 +893,9 @@ function progressMessage(message) {
return `계산 중 · ${message.stage ?? "worker"}`;
}
function runWorker(activity, payload, transfer) {
function runWorker(activity, payload, transfer, resultOptions = {}) {
const resultType = resultOptions.type ?? "result";
const resultKey = resultOptions.key ?? "run";
return new Promise((resolve, reject) => {
const worker = new Worker(new URL("./simulation-worker.js", import.meta.url), { type: "module" });
activity.worker = worker;
@@ -714,12 +906,16 @@ function runWorker(activity, payload, transfer) {
if (message.type === "progress") {
document.documentElement.dataset.runStage = message.stage ?? "worker";
setRunStatus(progressMessage(message));
if (activity.kind === "live") elements.liveStatus.textContent = progressMessage(message);
} else if (message.type === "fallback") {
activity.fallback = { code: message.code, message: message.message };
setRunStatus(`WebGPU 사용 불가 · CPU로 계속합니다 (${message.code})`);
} else if (message.type === "result") {
if (activity.kind === "live") {
elements.liveStatus.textContent = `WebGPU 사용 불가 · CPU로 계속합니다 (${message.code})`;
}
} else if (message.type === resultType) {
activity.finished = true;
resolve(message.run);
resolve(message[resultKey]);
} else if (message.type === "error") {
activity.finished = true;
const error = new Error(message.error?.message ?? "Worker 실행에 실패했습니다.");
@@ -763,6 +959,7 @@ async function runSimulation() {
state.resultDirty = false;
syncUnsavedState();
}
resetLiveTimeline();
const snapshot = {
arrayDefinition: structuredClone(state.arrayDefinition),
scene: structuredClone(state.scene),
@@ -770,6 +967,7 @@ async function runSimulation() {
};
const activity = {
jobId: `camera-${Date.now()}-${++jobSequence}`,
kind: "snapshot",
revision: state.revision,
abortController: new AbortController(),
worker: null,
@@ -824,6 +1022,9 @@ async function runSimulation() {
scan: snapshot.experiment.scan,
sources: sourcesForBand(snapshot.scene, definition),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
detectionThresholdRatio:
snapshot.experiment.evaluation?.detectionThresholdRatio ??
DEFAULT_DETECTION_THRESHOLD_RATIO,
});
evaluations.set(band.id, evaluation);
exportedEvaluations.set(band.id, exportBandEvaluation(evaluation));
@@ -896,6 +1097,263 @@ async function runSimulation() {
}
}
function createLiveExperiment(experiment) {
const preview = structuredClone(experiment);
const originalPoints = preview.scan.columns * preview.scan.rows;
if (originalPoints <= MAX_LIVE_SCAN_POINTS) {
return { experiment: preview, reduced: false, originalPoints };
}
const scale = Math.sqrt(MAX_LIVE_SCAN_POINTS / originalPoints);
preview.scan.columns = Math.max(8, Math.floor(preview.scan.columns * scale));
preview.scan.rows = Math.max(8, Math.floor(preview.scan.rows * scale));
while (preview.scan.columns * preview.scan.rows > MAX_LIVE_SCAN_POINTS) {
if (preview.scan.columns >= preview.scan.rows && preview.scan.columns > 8) {
preview.scan.columns -= 1;
} else if (preview.scan.rows > 8) {
preview.scan.rows -= 1;
} else {
break;
}
}
return { experiment: preview, reduced: true, originalPoints };
}
function reconcileLivePlan(plan, timeline) {
const frames = timeline.frames.map(({ run, ...frame }) => frame);
const frameInterval = frames.length > 1
? (frames.at(-1).sampleTime - frames[0].sampleTime) / (frames.length - 1)
: plan.duration;
return {
...plan,
frames,
frameCount: frames.length,
frameInterval,
effectiveMapHz: frameInterval > 0 ? 1 / frameInterval : 0,
};
}
function evaluateLiveFrames(snapshot, plan, frames) {
for (const frame of frames) {
const frameScene = scenePlaybackState(snapshot.scene, plan, frame.sampleTime).scene;
const evaluations = new Map();
for (const band of frame.run.bands) {
const definition = snapshot.experiment.frequencyBands.find(
(candidate) => candidate.id === band.id,
);
if (!definition) continue;
evaluations.set(
band.id,
evaluateDasBand({
rawMap: band.rawMap,
scan: snapshot.experiment.scan,
sources: sourcesForBand(frameScene, definition, frame.sampleTime),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
detectionThresholdRatio:
snapshot.experiment.evaluation?.detectionThresholdRatio ??
DEFAULT_DETECTION_THRESHOLD_RATIO,
}),
);
}
frame.evaluations = evaluations;
}
}
async function prepareLiveSimulation() {
if (state.active) return;
const validation = await validateCurrent();
if (!validation.valid) {
state.liveError = validation.issues.slice(0, 2).map(formatIssue).join(" · ");
renderLiveControls();
return;
}
if (!sceneHasTrajectories(state.scene)) {
state.liveError = "Scene에 2개 이상의 keyframe을 가진 trajectory가 없습니다.";
renderLiveControls();
return;
}
if (validation.revision !== state.revision || state.pendingInputOperations > 0) {
state.liveError = "입력 갱신이 끝난 뒤 다시 시도하세요.";
renderLiveControls();
await validateCurrent();
return;
}
resetLiveTimeline();
const preview = createLiveExperiment(state.experiment);
const snapshot = {
arrayDefinition: structuredClone(state.arrayDefinition),
scene: structuredClone(state.scene),
experiment: preview.experiment,
};
const plan = createAnalysisFramePlan(snapshot.scene, {
fftSize: snapshot.experiment.stft.fftSize,
targetMapHz: Number(elements.liveMapRate.value),
maxFrames: MAX_LIVE_ANALYSIS_FRAMES,
});
const activity = {
jobId: `camera-live-${Date.now()}-${++jobSequence}`,
kind: "live",
revision: state.revision,
abortController: new AbortController(),
worker: null,
reject: null,
canceled: false,
finished: false,
};
state.active = activity;
state.liveError = null;
document.documentElement.dataset.runStage = "live-assets";
setConfigurationLocked(true);
elements.prepareLiveButton.textContent = "준비 중…";
setRunStatus(`라이브 입력 고정 · ${plan.frameCount}개 map을 준비합니다…`);
try {
const assetBuffers = await assetRuntime.decodeAudioAssets(
snapshot.scene,
activity.abortController.signal,
({ completed, total, fileName }) => {
if (state.active !== activity) return;
elements.liveStatus.textContent = total > 0
? `오디오 decode · ${completed}/${total}${fileName ? ` · ${fileName}` : ""}`
: "Synthetic signal 준비 완료";
},
);
if (activity.canceled || state.active !== activity) throw createAbortError();
document.documentElement.dataset.runStage = "live-worker";
const timeline = await runWorker(
activity,
{
type: "run-timeline",
jobId: activity.jobId,
scene: snapshot.scene,
arrayDefinition: snapshot.arrayDefinition,
experiment: snapshot.experiment,
framePlan: plan,
assetBuffers,
},
assetRuntime.transferListForAudio(assetBuffers),
{ type: "timeline-result", key: "timeline" },
);
if (activity.canceled || state.active !== activity) throw createAbortError();
elements.liveStatus.textContent = "프레임별 peak와 빔 분포를 계산하고 있습니다…";
const resolvedPlan = reconcileLivePlan(plan, timeline);
evaluateLiveFrames(snapshot, resolvedPlan, timeline.frames);
state.live = {
snapshot,
plan: resolvedPlan,
frames: timeline.frames,
backend: timeline.backend,
timingMs: timeline.timingMs,
reducedScan: preview.reduced,
originalPoints: preview.originalPoints,
imageElement: assetRuntime.imageElement,
time: 0,
playing: false,
animationFrameId: 0,
playbackStartedAt: null,
playbackStartedTime: 0,
};
setRunStatus(
`라이브 준비 완료 · ${timeline.frames.length} maps · ` +
`${formatNumber(timeline.timingMs.total, 0)} ms`,
);
renderLiveControls();
renderSceneVisualization();
} catch (error) {
if (error?.name === "AbortError" || activity.canceled) {
setRunStatus("라이브 map 준비를 취소했습니다.");
} else {
state.liveError = error instanceof Error ? error.message : String(error);
setRunStatus(`라이브 준비 실패 · ${state.liveError}`);
}
} finally {
activity.worker?.terminate();
if (state.active === activity) state.active = null;
elements.prepareLiveButton.textContent = "라이브 준비";
setConfigurationLocked(false);
renderBandEditors();
await validateCurrent();
renderLiveControls();
}
}
function setLiveTime(value) {
const live = state.live;
if (!live) return;
live.time = Math.min(live.plan.duration, Math.max(0, Number(value) || 0));
const playback = playbackFrameAtTime(live.plan, live.time);
document.documentElement.dataset.liveFrame = String(playback.framePosition);
renderLiveControls();
renderSceneVisualization();
}
function livePlaybackTick(timestamp) {
const live = state.live;
if (!live?.playing) return;
if (live.playbackStartedAt === null) live.playbackStartedAt = timestamp;
const speed = Number(elements.liveSpeed.value) || 1;
const elapsed = ((timestamp - live.playbackStartedAt) / 1000) * speed;
const nextTime = live.playbackStartedTime + elapsed;
if (nextTime >= live.plan.duration) {
live.time = live.plan.duration;
stopLivePlayback(false);
renderLiveControls();
renderSceneVisualization();
return;
}
setLiveTime(nextTime);
live.animationFrameId = requestAnimationFrame(livePlaybackTick);
}
function toggleLivePlayback() {
const live = state.live;
if (!live) return;
if (live.playing) {
stopLivePlayback();
return;
}
if (live.time >= live.plan.duration) live.time = 0;
live.playing = true;
live.playbackStartedAt = null;
live.playbackStartedTime = live.time;
live.animationFrameId = requestAnimationFrame(livePlaybackTick);
renderLiveControls();
renderSceneVisualization();
}
async function loadMovingExample() {
if (state.active) return;
beginInputOperation();
try {
setRunStatus("이동 음원 예제를 불러오고 있습니다…");
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson(MOVING_EXAMPLE_URLS.arrayDefinition),
loadJson(MOVING_EXAMPLE_URLS.scene),
loadJson(MOVING_EXAMPLE_URLS.experiment),
]);
const [arrayIssues, sceneIssues] = await Promise.all([
validateArrayDefinition(arrayDefinition),
validateScene(scene),
]);
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
const issues = [...arrayIssues, ...sceneIssues, ...experimentIssues];
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
state.arrayDefinition = arrayDefinition;
state.scene = scene;
state.experiment = applyExperimentDefaults(experiment);
state.sceneDirty = false;
state.experimentDirty = false;
state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id));
assetRuntime.reset();
markChanged({ syncControls: true, renderBands: true, dirty: "none" });
renderAssets();
setRunStatus("이동 음원 예제를 열었습니다. ‘라이브 준비’를 누르세요.");
} catch (error) {
setTransientError(error, "이동 예제 불러오기 실패");
} finally {
endInputOperation();
}
}
function cancelSimulation() {
const activity = state.active;
if (!activity || activity.canceled) return;
@@ -972,7 +1430,7 @@ async function importDocument(kind, input) {
state.sceneDirty = false;
assetRuntime.reset();
} else {
state.experiment = value;
state.experiment = applyExperimentDefaults(value);
state.experimentDirty = false;
}
state.visibleBandIds = new Set(state.experiment.frequencyBands.map((band) => band.id));
@@ -1067,6 +1525,13 @@ function installDocumentInputListeners() {
state.experiment.beamforming.dynamicRangeDb = value;
markChanged();
});
elements.detectionThreshold.addEventListener("change", () => {
const value = readFiniteInput(elements.detectionThreshold);
if (value === null) return syncControls();
state.experiment.evaluation ??= {};
state.experiment.evaluation.detectionThresholdRatio = value / 100;
markChanged();
});
}
function installBandListeners() {
@@ -1125,6 +1590,22 @@ function installBandListeners() {
function installActionListeners() {
elements.runButton.addEventListener("click", runSimulation);
elements.cancelButton.addEventListener("click", cancelSimulation);
elements.loadMovingExampleButton.addEventListener("click", loadMovingExample);
elements.prepareLiveButton.addEventListener("click", prepareLiveSimulation);
elements.livePlayButton.addEventListener("click", toggleLivePlayback);
elements.liveTime.addEventListener("input", () => {
stopLivePlayback(false);
setLiveTime(elements.liveTime.valueAsNumber);
});
elements.liveMapRate.addEventListener("change", () => {
resetLiveTimeline();
renderSceneVisualization();
});
elements.liveSpeed.addEventListener("change", () => {
if (!state.live?.playing) return;
state.live.playbackStartedAt = performance.now();
state.live.playbackStartedTime = state.live.time;
});
elements.sceneInput.addEventListener("change", () => importDocument("scene", elements.sceneInput));
elements.arrayInput.addEventListener("change", () => importDocument("array", elements.arrayInput));
elements.experimentInput.addEventListener(
@@ -1156,6 +1637,7 @@ function installActionListeners() {
}
});
elements.overlayOpacity.addEventListener("input", renderSceneVisualization);
elements.showBeamSpread.addEventListener("change", renderSceneVisualization);
elements.showTruth.addEventListener("change", renderSceneVisualization);
elements.exportSceneButton.addEventListener("click", async () => {
const issues = await validateScene(state.scene);
@@ -1226,6 +1708,7 @@ function installActionListeners() {
event.returnValue = "";
});
window.addEventListener("unload", () => {
stopLivePlayback(false);
cancelSimulation();
sceneVisualization.destroy();
arrayVisualization.destroy();
@@ -1239,6 +1722,7 @@ async function initialize() {
installActionListeners();
setEditableControlsDisabled(true);
document.documentElement.dataset.runState = "idle";
renderLiveControls();
try {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson(DEFAULT_DOCUMENT_URLS.arrayDefinition),
@@ -1247,7 +1731,7 @@ async function initialize() {
]);
state.arrayDefinition = arrayDefinition;
state.scene = scene;
state.experiment = experiment;
state.experiment = applyExperimentDefaults(experiment);
state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id));
syncControls();
renderBandEditors();
@@ -1258,6 +1742,7 @@ async function initialize() {
renderResult();
renderHistory();
renderSceneVisualization();
renderLiveControls();
syncUnsavedState();
await validateCurrent();
setEditableControlsDisabled(false);
+48 -8
View File
@@ -62,10 +62,30 @@ export function shiftSceneDistance(scene, experiment, nextDistance) {
const previousDistance = experiment.scan.z;
const delta = nextDistance - previousDistance;
experiment.scan.z = nextDistance;
for (const source of scene.sources) source.position[2] += delta;
for (const source of scene.sources) {
source.position[2] += delta;
for (const keyframe of source.trajectory ?? []) keyframe.position[2] += delta;
}
if (scene.image?.plane) scene.image.plane.z += delta;
}
function sourceGeometryPositions(scene) {
return (scene.sources ?? []).flatMap((source) => {
const candidates = [source.position, ...(source.trajectory ?? []).map(({ position }) => position)];
const uniquePositions = new Map();
for (const position of candidates) {
if (
Array.isArray(position) &&
position.length === 3 &&
position.every(Number.isFinite)
) {
uniquePositions.set(position.join(","), position);
}
}
return [...uniquePositions.values()];
});
}
export function alignExperimentToScene(experiment, scene) {
const plane = scene.image?.plane;
if (plane) {
@@ -79,13 +99,7 @@ export function alignExperimentToScene(experiment, scene) {
return "image-plane";
}
const positions = (scene.sources ?? [])
.map((source) => source.position)
.filter((position) =>
Array.isArray(position) &&
position.length === 3 &&
position.every(Number.isFinite),
);
const positions = sourceGeometryPositions(scene);
if (positions.length === 0) return "unchanged";
const paddedBounds = (values) => {
@@ -121,12 +135,38 @@ function finiteMetricSubset(metrics = {}) {
);
}
function beamSpreadForResult(spread) {
if (!spread || typeof spread !== "object") return null;
return {
calibrated: false,
method: spread.method,
halfPowerRatio: Number(spread.halfPowerRatio),
thresholdValue: Number(spread.thresholdValue),
pixelCount: Number(spread.pixelCount),
centroid: spread.centroid.map(Number),
covarianceM2: {
xx: Number(spread.covarianceM2.xx),
xy: Number(spread.covarianceM2.xy),
yy: Number(spread.covarianceM2.yy),
},
ellipse: {
axisScale: spread.ellipse.axisScale,
angleReference: spread.ellipse.angleReference,
majorRmsRadiusM: Number(spread.ellipse.majorRmsRadiusM),
minorRmsRadiusM: Number(spread.ellipse.minorRmsRadiusM),
angleDeg: Number(spread.ellipse.angleDeg),
},
};
}
function peakForResult(peak) {
const result = {
position: peak.position.map(Number),
value: Number(peak.value),
};
if (peak.sourceId) result.sourceId = peak.sourceId;
const beamSpread = beamSpreadForResult(peak.beamSpread);
if (beamSpread) result.beamSpread = beamSpread;
if (Number.isFinite(peak.localizationErrorM)) result.localizationErrorM = peak.localizationErrorM;
if (Number.isFinite(peak.localizationErrorPixels)) {
result.localizationErrorPixels = peak.localizationErrorPixels;
+148 -10
View File
@@ -1,4 +1,5 @@
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
import { sampleQuasiStaticWindow } from "/packages/acoustics-core/trajectory.js";
import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js";
function post(type, jobId, payload = {}, transfer = []) {
@@ -17,11 +18,155 @@ function serializeError(error) {
};
}
function signalStats(synthesis) {
return {
preNormalizationPeak: synthesis.preNormalizationPeak,
appliedScale: synthesis.appliedScale,
clippedSampleCount: synthesis.clippedSampleCount,
peak: synthesis.peak,
};
}
function collectBandTransfers(run, transfer) {
for (const band of run.bands) {
transfer.push(band.rawMap.buffer, band.displayMap.buffer);
}
}
async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuffers, framePlan }) {
if (!Array.isArray(framePlan?.frames) || framePlan.frames.length === 0) {
throw new TypeError("framePlan.frames must be a non-empty array.");
}
const totalStartedAt = performance.now();
const totalFrames = framePlan.frames.length;
const frames = [];
const transfer = [];
let signalGeneration = 0;
let beamforming = 0;
post("progress", jobId, {
stage: "timeline-start",
message: `라이브 맵 ${totalFrames}개를 준비하고 있습니다…`,
completedFrames: 0,
totalFrames,
});
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += 1) {
const plannedFrame = framePlan.frames[frameIndex];
const synthesisWindow = sampleQuasiStaticWindow(
scene,
plannedFrame.startTime,
plannedFrame.endTime,
);
post("progress", jobId, {
stage: "timeline-signal",
message: `라이브 신호 합성 중 · ${frameIndex + 1}/${totalFrames}`,
frameIndex,
completedFrames: frameIndex,
totalFrames,
});
const frameStartedAt = performance.now();
const signalStartedAt = performance.now();
const synthesis = synthesizeMicrophoneSignals({
scene: synthesisWindow.scene,
arrayDefinition,
assetBuffers,
minimumDistance: experiment.beamforming.minimumDistance,
signalOffsets: synthesisWindow.signalOffsets,
});
const frameSignalGeneration = performance.now() - signalStartedAt;
signalGeneration += frameSignalGeneration;
const beamformingStartedAt = performance.now();
const das = await computeDasWithBackend({
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
scene: synthesisWindow.scene,
experiment,
onFallback: ({ error }) =>
post("fallback", jobId, {
code: error.code,
message: error.message,
frameIndex,
totalFrames,
}),
onProgress: (progress) =>
post("progress", jobId, {
stage: `timeline-${progress.stage}`,
frameIndex,
completedFrames: frameIndex,
totalFrames,
progress,
}),
});
const frameBeamforming = performance.now() - beamformingStartedAt;
beamforming += frameBeamforming;
collectBandTransfers(das, transfer);
frames.push({
index: plannedFrame.index ?? frameIndex,
startTime: synthesisWindow.startTime,
endTime: synthesisWindow.endTime,
sampleTime: synthesisWindow.sampleTime,
duration: synthesisWindow.duration,
run: {
...das,
signalStats: signalStats(synthesis),
timingMs: {
total: performance.now() - frameStartedAt,
signalGeneration: frameSignalGeneration,
beamforming: frameBeamforming,
},
},
});
post("progress", jobId, {
stage: "timeline-frame-complete",
message: `라이브 맵 준비 · ${frameIndex + 1}/${totalFrames}`,
frameIndex,
completedFrames: frameIndex + 1,
totalFrames,
});
}
const backends = new Set(frames.map((frame) => frame.run.backend));
const fallbacks = frames
.filter((frame) => frame.run.fallback)
.map((frame) => ({ frameIndex: frame.index, ...frame.run.fallback }));
post(
"timeline-result",
jobId,
{
timeline: {
backend: backends.size === 1 ? frames[0].run.backend : "mixed",
requestedBackend: experiment.beamforming.backend ?? "auto",
duration: scene.duration,
frameCount: frames.length,
frames,
...(fallbacks.length > 0 ? { fallbacks } : {}),
timingMs: {
total: performance.now() - totalStartedAt,
signalGeneration,
beamforming,
},
},
},
transfer,
);
}
self.addEventListener("message", async (event) => {
if (event.data?.type !== "run") return;
if (event.data?.type !== "run" && event.data?.type !== "run-timeline") return;
const { jobId, scene, arrayDefinition, experiment, assetBuffers } = event.data;
const totalStartedAt = performance.now();
try {
if (event.data.type === "run-timeline") {
await runTimeline(event.data);
return;
}
post("progress", jobId, { stage: "signal", message: "192채널 자유음장 신호를 합성하고 있습니다…" });
const signalStartedAt = performance.now();
const synthesis = synthesizeMicrophoneSignals({
@@ -52,21 +197,14 @@ self.addEventListener("message", async (event) => {
});
const beamforming = performance.now() - beamformingStartedAt;
const transfer = [];
for (const band of das.bands) {
transfer.push(band.rawMap.buffer, band.displayMap.buffer);
}
collectBandTransfers(das, transfer);
post(
"result",
jobId,
{
run: {
...das,
signalStats: {
preNormalizationPeak: synthesis.preNormalizationPeak,
appliedScale: synthesis.appliedScale,
clippedSampleCount: synthesis.clippedSampleCount,
peak: synthesis.peak,
},
signalStats: signalStats(synthesis),
timingMs: {
total: performance.now() - totalStartedAt,
signalGeneration,
+18 -1
View File
@@ -35,6 +35,18 @@
.range-field, .check-field { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .7rem; font-weight: 650; }
.range-field input { width: 110px; accent-color: var(--mint); }
.check-field input { width: 17px; height: 17px; accent-color: var(--mint); }
.live-panel { margin-top: 12px; padding: 15px 16px; border: 1px solid rgb(88 199 255 / 24%); border-radius: 16px; background: linear-gradient(135deg, rgb(88 199 255 / 7%), rgb(101 242 191 / 3%)); }
.live-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.live-heading h2 { margin: 0; font-size: .98rem; }
.live-heading p:not(.eyebrow) { margin: 5px 0 0; color: var(--muted); font-size: .7rem; }
.live-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.live-controls { display: grid; grid-template-columns: minmax(220px, 1fr) auto auto; gap: 12px; align-items: end; margin-top: 14px; }
.live-scrubber, .compact-select { display: grid; gap: 6px; color: var(--muted); font-size: .66rem; font-weight: 650; }
.live-scrubber input { width: 100%; accent-color: var(--cyan); }
.compact-select select { min-height: 34px; padding: 7px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; }
.live-note { margin: 10px 0 0; color: var(--muted); font-size: .64rem; line-height: 1.55; }
.live-panel.is-ready { border-color: rgb(101 242 191 / 42%); }
.live-panel.is-playing { box-shadow: inset 3px 0 0 var(--cyan); }
.result-section, .history-section { margin-top: 28px; padding: 20px; border: 1px solid var(--line); border-radius: 18px; background: rgb(255 255 255 / 2%); }
.section-heading h2 { margin: 0; font-size: 1.05rem; }
.result-chip { padding: 6px 9px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: .68rem; }
@@ -119,13 +131,18 @@
.header-actions .button, .workspace-tools .button { flex: 1 1 calc(50% - 5px); }
.scene-workspace { padding: 18px; }
.workspace-heading { flex-direction: column; }
.live-heading { align-items: stretch; flex-direction: column; }
.live-actions { justify-content: flex-start; }
.live-controls { grid-template-columns: 1fr 1fr; }
.live-scrubber { grid-column: 1 / -1; }
.scene-canvas-card { min-height: 380px; }
#scene-canvas { height: 420px; }
.history-card { grid-template-columns: 1fr 1fr; }
.history-card strong { grid-column: 1 / -1; }
}
@media (max-width: 430px) {
.field-grid, .band-editor, .output-actions { grid-template-columns: 1fr; }
.field-grid, .band-editor, .output-actions, .live-controls { grid-template-columns: 1fr; }
.live-scrubber { grid-column: auto; }
.band-editor__remove { min-height: 34px; }
.metric-grid { grid-template-columns: 1fr 1fr; }
}
+250
View File
@@ -0,0 +1,250 @@
import { sceneAtTime } from "/packages/acoustics-core/trajectory.js";
export const DEFAULT_TARGET_MAP_HZ = 8;
export const DEFAULT_MAX_ANALYSIS_FRAMES = 120;
function requirePositiveFinite(value, label) {
if (!Number.isFinite(value) || value <= 0) {
throw new RangeError(`${label} must be a positive finite number.`);
}
return value;
}
function requirePositiveInteger(value, label) {
if (!Number.isInteger(value) || value <= 0) {
throw new RangeError(`${label} must be a positive integer.`);
}
return value;
}
function requireScene(scene) {
if (!scene || typeof scene !== "object") {
throw new TypeError("scene must be an object.");
}
requirePositiveFinite(scene.duration, "scene.duration");
requirePositiveFinite(scene.sampleRate, "scene.sampleRate");
if (!Array.isArray(scene.sources)) {
throw new TypeError("scene.sources must be an array.");
}
return scene;
}
function requireFramePlan(plan) {
if (!plan || typeof plan !== "object" || !Array.isArray(plan.frames) || plan.frames.length === 0) {
throw new TypeError("plan.frames must be a non-empty array.");
}
requirePositiveFinite(plan.duration, "plan.duration");
let previousSampleTime = -Infinity;
plan.frames.forEach((frame, index) => {
if (
!frame ||
!Number.isFinite(frame.startTime) ||
!Number.isFinite(frame.endTime) ||
!Number.isFinite(frame.sampleTime) ||
frame.startTime < 0 ||
frame.endTime <= frame.startTime ||
frame.endTime > plan.duration ||
frame.sampleTime < frame.startTime ||
frame.sampleTime > frame.endTime ||
frame.sampleTime < previousSampleTime
) {
throw new RangeError(`plan.frames[${index}] contains an invalid time range.`);
}
previousSampleTime = frame.sampleTime;
});
return plan;
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
/**
* Return whether at least one source has a usable trajectory. This is a
* feature check only; scene contract validation remains the caller's job.
*/
export function sceneHasTrajectories(scene) {
return Array.isArray(scene?.sources) && scene.sources.some(
(source) => Array.isArray(source?.trajectory) && source.trajectory.length >= 2,
);
}
/**
* Build a serializable, deterministic set of analysis windows for a scene.
*
* Frames are distributed from the beginning to the end of the scene. The
* windows are wide enough to avoid gaps, never shorter than one FFT when the
* scene itself permits it, and capped so precomputation cannot grow without
* bound. A shorter-than-FFT scene produces one full-scene frame which the
* signal/STFT layer can zero-pad.
*/
export function createAnalysisFramePlan(scene, options = {}) {
requireScene(scene);
const fftSize = requirePositiveInteger(options.fftSize, "fftSize");
const targetMapHz = requirePositiveFinite(
options.targetMapHz ?? DEFAULT_TARGET_MAP_HZ,
"targetMapHz",
);
const maxFrames = requirePositiveInteger(
options.maxFrames ?? DEFAULT_MAX_ANALYSIS_FRAMES,
"maxFrames",
);
const requestedWindowDuration = options.windowDuration === undefined
? 1 / targetMapHz
: requirePositiveFinite(options.windowDuration, "windowDuration");
const duration = scene.duration;
const minimumWindowDuration = fftSize / scene.sampleRate;
const desiredFrameCount = Math.max(1, Math.ceil(duration * targetMapHz));
let frameCount = Math.min(desiredFrameCount, maxFrames);
let windowDuration = Math.min(
duration,
Math.max(minimumWindowDuration, requestedWindowDuration, duration / frameCount),
);
// More than one full-scene window would produce duplicate analysis frames.
if (windowDuration >= duration) {
frameCount = 1;
windowDuration = duration;
}
const frames = [];
if (frameCount === 1) {
frames.push({
index: 0,
startTime: 0,
endTime: duration,
sampleTime: duration / 2,
duration,
});
} else {
const firstSampleTime = windowDuration / 2;
const lastSampleTime = duration - windowDuration / 2;
const sampleInterval = (lastSampleTime - firstSampleTime) / (frameCount - 1);
for (let index = 0; index < frameCount; index += 1) {
const sampleTime = index === frameCount - 1
? lastSampleTime
: firstSampleTime + sampleInterval * index;
let startTime = index === 0 ? 0 : sampleTime - windowDuration / 2;
if (index > 0 && startTime > frames[index - 1].endTime) {
startTime = frames[index - 1].endTime;
}
const endTime = index === frameCount - 1 ? duration : startTime + windowDuration;
frames.push({
index,
startTime,
endTime,
sampleTime: startTime + (endTime - startTime) / 2,
duration: endTime - startTime,
});
}
}
const frameInterval = frameCount > 1
? (frames[frameCount - 1].sampleTime - frames[0].sampleTime) / (frameCount - 1)
: duration;
return {
duration,
sampleRate: scene.sampleRate,
fftSize,
targetMapHz,
maxFrames,
minimumWindowDuration,
windowDuration,
frameInterval,
effectiveMapHz: frameInterval > 0 ? 1 / frameInterval : 0,
frameCount,
zeroPaddingRequired: duration < minimumWindowDuration,
frames,
};
}
/**
* Select the precomputed frames surrounding an arbitrary playback time.
* `progress` is the blend amount from the left frame to the right frame, while
* `frameIndex` identifies the nearest single frame for renderers that do not
* blend heatmaps.
*/
export function playbackFrameAtTime(plan, time) {
requireFramePlan(plan);
if (!Number.isFinite(time)) throw new TypeError("time must be finite.");
const clampedTime = clamp(time, 0, plan.duration);
const { frames } = plan;
const lastIndex = frames.length - 1;
if (frames.length === 1 || clampedTime <= frames[0].sampleTime) {
return {
time: clampedTime,
sceneProgress: clampedTime / plan.duration,
leftFrameIndex: 0,
rightFrameIndex: 0,
frameIndex: 0,
framePosition: 0,
progress: 0,
};
}
if (clampedTime >= frames[lastIndex].sampleTime) {
return {
time: clampedTime,
sceneProgress: clampedTime / plan.duration,
leftFrameIndex: lastIndex,
rightFrameIndex: lastIndex,
frameIndex: lastIndex,
framePosition: lastIndex,
progress: 0,
};
}
let low = 0;
let high = lastIndex;
while (high - low > 1) {
const middle = (low + high) >> 1;
if (frames[middle].sampleTime <= clampedTime) low = middle;
else high = middle;
}
if (clampedTime === frames[low].sampleTime) {
return {
time: clampedTime,
sceneProgress: clampedTime / plan.duration,
leftFrameIndex: low,
rightFrameIndex: low,
frameIndex: low,
framePosition: low,
progress: 0,
};
}
const span = frames[high].sampleTime - frames[low].sampleTime;
const progress = (clampedTime - frames[low].sampleTime) / span;
return {
time: clampedTime,
sceneProgress: clampedTime / plan.duration,
leftFrameIndex: low,
rightFrameIndex: high,
frameIndex: progress < 0.5 ? low : high,
framePosition: low + progress,
progress,
};
}
/**
* Resolve moving sources at playback time while also returning the map-frame
* blend metadata needed by the renderer. Inputs are not mutated.
*/
export function scenePlaybackState(scene, plan, time) {
requireScene(scene);
const playback = playbackFrameAtTime(plan, time);
if (Math.abs(scene.duration - plan.duration) > Number.EPSILON * Math.max(1, scene.duration)) {
throw new RangeError("scene.duration must match plan.duration.");
}
const sampledScene = sceneAtTime(scene, playback.time);
return {
...playback,
scene: sampledScene,
sourcePositions: sampledScene.sources.map((source) => ({
sourceId: source.id,
position: [...source.position],
})),
};
}
+71 -1
View File
@@ -411,6 +411,74 @@ function peaksForBand(band, evaluations) {
return Array.isArray(peaks) ? peaks : [];
}
function traceBeamSpreadEllipse(context, spread, scan, plot, radiusScale) {
const centroid = spread?.centroid;
const ellipse = spread?.ellipse;
if (
!Array.isArray(centroid) ||
!finiteNumber(centroid[0]) ||
!finiteNumber(centroid[1]) ||
!finiteNumber(ellipse?.majorRmsRadiusM) ||
!finiteNumber(ellipse?.minorRmsRadiusM) ||
!finiteNumber(ellipse?.angleDeg) ||
ellipse.majorRmsRadiusM < 0 ||
ellipse.minorRmsRadiusM < 0
) {
return false;
}
const major = ellipse.majorRmsRadiusM * radiusScale;
const minor = ellipse.minorRmsRadiusM * radiusScale;
if (!(major > 0) && !(minor > 0)) return false;
const angle = (ellipse.angleDeg * Math.PI) / 180;
const cosine = Math.cos(angle);
const sine = Math.sin(angle);
const segmentCount = 64;
context.beginPath();
for (let segment = 0; segment <= segmentCount; segment += 1) {
const phase = (segment / segmentCount) * Math.PI * 2;
const alongMajor = major * Math.cos(phase);
const alongMinor = minor * Math.sin(phase);
const position = [
centroid[0] + alongMajor * cosine - alongMinor * sine,
centroid[1] + alongMajor * sine + alongMinor * cosine,
];
const point = scanPositionToCanvas(position, scan, plot);
if (segment === 0) context.moveTo(point.x, point.y);
else context.lineTo(point.x, point.y);
}
context.closePath();
return true;
}
function drawBeamSpreadContours(context, bands, evaluations, scan, plot) {
context.save();
context.beginPath();
context.rect(plot.x, plot.y, plot.width, plot.height);
context.clip();
for (const band of bands) {
if (!band || band.visible === false) continue;
const color = /^#[0-9a-f]{6}$/i.test(band.color ?? "") ? band.color : "#65f2bf";
for (const peak of peaksForBand(band, evaluations).slice(0, 128)) {
const spread = peak?.beamSpread;
if (!spread || spread.calibrated !== false) continue;
context.strokeStyle = color;
context.fillStyle = color;
context.lineWidth = 1.2;
context.setLineDash([]);
if (traceBeamSpreadEllipse(context, spread, scan, plot, 2)) {
context.globalAlpha = 0.07;
context.fill();
context.globalAlpha = 0.45;
context.stroke();
}
context.setLineDash([4, 4]);
context.globalAlpha = 0.78;
if (traceBeamSpreadEllipse(context, spread, scan, plot, 1)) context.stroke();
}
}
context.restore();
}
function drawBandPeakMarkers(context, bands, evaluations, scan, plot) {
for (const band of bands) {
if (!band || band.visible === false) continue;
@@ -483,6 +551,7 @@ function normalizeSceneModel(model) {
value.dynamicRangeDb ?? value.experiment?.beamforming?.dynamicRangeDb ?? DEFAULT_DYNAMIC_RANGE_DB,
opacity: value.opacity ?? 0.78,
showTruth: value.showTruth ?? true,
showBeamSpread: value.showBeamSpread ?? true,
};
}
@@ -518,6 +587,7 @@ function drawScene(context, size, sourceModel, canvas) {
// Map storage is row-major from yMax to yMin, which is the Canvas top-to-bottom order.
context.drawImage(heatmap.canvas, plot.x, plot.y, plot.width, plot.height);
context.restore();
if (model.showBeamSpread) drawBeamSpreadContours(context, bands, model.evaluations, scan, plot);
drawBandPeakMarkers(context, bands, model.evaluations, scan, plot);
drawBandLegend(context, heatmap.activeBands, plot);
}
@@ -530,7 +600,7 @@ function drawScene(context, size, sourceModel, canvas) {
* `render()` coalesces repeated updates into one animation frame. Use
* `renderNow()` only for deterministic capture/tests. Accepted model fields:
* `{ experiment|scan, result|bands, scene|sources, image, imagePlane,
* evaluations, dynamicRangeDb, opacity, showTruth }`.
* evaluations, dynamicRangeDb, opacity, showTruth, showBeamSpread }`.
*/
export function createSceneVisualization(canvas) {
if (!canvas) throw new TypeError("scene canvas가 필요합니다.");
+20 -6
View File
@@ -12,14 +12,23 @@
## 기본 사용법
1. **배경 이미지**를 선택하거나 이미지 없이 grid를 사용합니다.
2. **오디오 추가**로 source 또는 ambient에 사용할 파일을 연결합니다.
3. **+ 음원 추가**를 누르고 카드를 선택한 뒤 canvas를 클릭하거나 x/y/z를 입력합니다.
4. source 종류를 설정합니다.
2. 녹음 파일을 음원으로 쓸 때는 **+ 오디오 파일 음원**을 누르고 파일을 선택합니다. 파일 asset과
이를 사용하는 source가 함께 생성되고 새 source가 자동으로 선택됩니다.
3. 합성 신호가 필요하면 **+ 합성 음원**을 누릅니다.
4. 선택된 카드를 확인한 뒤 canvas를 클릭하거나 x/y/z를 입력해 위치를 지정합니다.
5. 필요하면 source 종류를 변경하거나 세부 값을 설정합니다.
- `Tone`: 주파수와 위상을 갖는 합성 정현파
- `Noise`: seed로 재현되는 white 또는 pink noise
- `Audio asset`: 선택한 파일의 channel을 사용하며 짧은 파일은 반복 가능
5. 필요하면 ambient 종류와 gain을 설정합니다.
6. 검증 카드가 **저장 가능한 장면**이면 **Scene JSON 저장**을 누릅니다.
6. 움직이는 음원은 **시간에 따라 음원 이동**을 켭니다. 현재 위치에서 시작해 Scene 끝까지 이어지는
기본 두 점 경로가 생성됩니다. 경로점의 절대 시간과 x/y/z를 수정하거나 점을 추가·삭제할 수 있습니다.
7. **경로 미리보기** slider로 시간별 위치를 확인합니다. 번호를 눌러 경로점을 선택한 뒤 canvas를
클릭하면 선택한 점의 x/y가 바뀝니다. 첫 경로점은 고정 장면 호환용 `source.position`에도 동기화됩니다.
8. 필요하면 ambient 종류와 gain을 설정합니다.
9. 검증 카드가 **저장 가능한 장면**이면 **Scene JSON 저장**을 누릅니다.
오디오 파일은 PCM WAV가 가장 안정적입니다. MP3, FLAC, OGG, M4A 등의 실제 decode 지원 여부는
브라우저와 운영체제 codec에 따라 달라지며, 지원하지 않는 파일은 미리 듣기 시 오류로 표시됩니다.
길이, sample rate, 음속, seed와 mix 정규화도 Scene JSON에 저장됩니다. 이미지가 있으면 표시
평면의 x/y 범위, z와 image pixel 크기가 ground truth 좌표 변환에 사용됩니다.
@@ -40,7 +49,8 @@ id, 종류, 파일명과 SHA-256이 들어갑니다.
- 파일 한 개는 최대 128 MiB, 연결 파일 합계는 최대 512 MiB입니다.
- decode된 오디오의 합계는 최대 192 MiB입니다.
- Scene을 다시 불러오면 브라우저 권한이 없으므로 원본 이미지와 오디오를 다시 선택해야 합니다.
- Scene을 다시 불러오면 브라우저 권한이 없으므로 **원본 오디오 연결**에서 같은 오디오를 다시
선택해야 합니다. 이 동작은 새 source를 만들지 않고 manifest의 SHA-256으로 기존 asset을 연결합니다.
- 같은 이름의 파일도 SHA-256이 manifest와 다르면 기존 asset으로 연결되지 않습니다.
- 사용 중인 asset은 먼저 source, ambient 또는 image 참조를 바꾼 뒤 제거할 수 있습니다.
@@ -63,3 +73,7 @@ id, 종류, 파일명과 SHA-256이 들어갑니다.
Builder의 mix는 재현 가능한 source recipe와 위치 ground truth를 만드는 단계입니다. 자유음장 전파,
마이크 calibration과 DAS는 Acoustic Camera에서 수행합니다. 실제 방의 반사·잔향·차폐를 Builder가
추정하지 않습니다.
이동 경로는 Scene 시작 기준의 keyframe을 직선 보간합니다. Builder의 mono 미리 듣기는 공간 위치를
소리로 표현하지 않으므로, 움직임은 canvas에서 확인하고 실제 시간창별 위치 추정은 Acoustic Camera에서
실행합니다. 경로점 사이의 Doppler 효과나 연속적인 전파 시간 변화는 현재 모델 범위에 포함되지 않습니다.
+63 -8
View File
@@ -1,4 +1,5 @@
import { SOURCE_COLORS } from "./model.js";
import { sourcePositionAtTime } from "/packages/acoustics-core/trajectory.js";
function finitePlane(plane) {
return [plane.xMin, plane.xMax, plane.yMin, plane.yMax].every(Number.isFinite) &&
@@ -45,9 +46,50 @@ function drawGrid(context, width, height, plane) {
context.textAlign = "left";
}
function drawSource(context, source, index, selected, plane, width, height) {
if (!source.position?.every(Number.isFinite)) return;
const point = coordinateToPixel(source.position, plane, width, height);
function drawTrajectory(context, source, index, selected, selectedKeyframeIndex, plane, width, height) {
if (!Array.isArray(source.trajectory) || source.trajectory.length < 2) return;
const points = source.trajectory.map(({ position }) => coordinateToPixel(position, plane, width, height));
const color = SOURCE_COLORS[index % SOURCE_COLORS.length];
context.save();
context.strokeStyle = color;
context.globalAlpha = selected ? 0.85 : 0.28;
context.lineWidth = selected ? 2.5 : 1.5;
context.setLineDash([7, 5]);
context.beginPath();
points.forEach((point, pointIndex) => {
if (pointIndex === 0) context.moveTo(point.x, point.y);
else context.lineTo(point.x, point.y);
});
context.stroke();
context.setLineDash([]);
points.forEach((point, pointIndex) => {
context.globalAlpha = selected ? 1 : 0.42;
context.fillStyle = color;
context.beginPath();
context.arc(point.x, point.y, pointIndex === selectedKeyframeIndex && selected ? 6 : 4, 0, Math.PI * 2);
context.fill();
if (pointIndex === selectedKeyframeIndex && selected) {
context.strokeStyle = "#fff";
context.lineWidth = 2;
context.beginPath();
context.arc(point.x, point.y, 9, 0, Math.PI * 2);
context.stroke();
}
});
context.restore();
}
function positionAtPreviewTime(source, previewTime) {
try {
return sourcePositionAtTime(source, previewTime);
} catch {
return source.position;
}
}
function drawSource(context, source, position, index, selected, plane, width, height) {
if (!position?.every(Number.isFinite)) return;
const point = coordinateToPixel(position, plane, width, height);
if (point.x < -30 || point.x > width + 30 || point.y < -30 || point.y > height + 30) return;
const color = SOURCE_COLORS[index % SOURCE_COLORS.length];
context.save();
@@ -81,8 +123,8 @@ export function createSceneCanvas(canvas, emptyElement, badgeElement, onMove) {
const context = canvas.getContext("2d");
let current = null;
function render(scene, image, plane, selectedSourceId) {
current = { scene, image, plane, selectedSourceId };
function render(scene, image, plane, selectedSourceId, previewTime = 0, selectedKeyframeIndex = 0) {
current = { scene, image, plane, selectedSourceId, previewTime, selectedKeyframeIndex };
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, rect.width);
const height = Math.max(1, rect.height);
@@ -111,12 +153,22 @@ export function createSceneCanvas(canvas, emptyElement, badgeElement, onMove) {
context.fillRect(0, 0, width, height);
}
scene.sources.forEach((source, index) => {
drawSource(context, source, index, source.id === selectedSourceId, plane, width, height);
drawTrajectory(
context, source, index, source.id === selectedSourceId,
source.id === selectedSourceId ? selectedKeyframeIndex : -1,
plane, width, height,
);
});
scene.sources.forEach((source, index) => {
drawSource(
context, source, positionAtPreviewTime(source, previewTime), index,
source.id === selectedSourceId, plane, width, height,
);
});
emptyElement.classList.toggle("is-hidden", Boolean(image));
badgeElement.textContent =
`x ${plane.xMin.toFixed(2)}…${plane.xMax.toFixed(2)} m · ` +
`y ${plane.yMin.toFixed(2)}…${plane.yMax.toFixed(2)} m`;
`y ${plane.yMin.toFixed(2)}…${plane.yMax.toFixed(2)} m · t ${previewTime.toFixed(2)} s`;
}
canvas.addEventListener("pointerdown", (event) => {
@@ -128,7 +180,10 @@ export function createSceneCanvas(canvas, emptyElement, badgeElement, onMove) {
});
new ResizeObserver(() => {
if (current) render(current.scene, current.image, current.plane, current.selectedSourceId);
if (current) render(
current.scene, current.image, current.plane, current.selectedSourceId,
current.previewTime, current.selectedKeyframeIndex,
);
}).observe(canvas);
return { render };
+23 -4
View File
@@ -37,7 +37,7 @@
</label>
<button class="button button--danger is-hidden" id="remove-image-button" type="button">이미지 해제</button>
<label class="button button--ghost file-button">
오디오 추가
원본 오디오 연결
<input id="audio-input" type="file" accept="audio/*,.wav,.flac,.mp3,.ogg,.m4a" multiple />
</label>
<label class="button button--ghost file-button">
@@ -59,16 +59,22 @@
<div class="source-strip-heading">
<div>
<h2>음원 ground truth</h2>
<p>카드를 선택한 뒤 canvas를 클릭해 위치를 지정합니다.</p>
<p>파일 음원은 선택 즉시 생성됩니다. 카드를 선택한 뒤 canvas를 클릭해 위치를 지정하세요.</p>
</div>
<div class="source-actions">
<button class="button button--ghost" id="add-source-button" type="button">+ 합성 음원</button>
<label class="button button--primary file-button">
+ 오디오 파일 음원
<input id="audio-source-input" type="file" accept="audio/*,.wav,.flac,.mp3,.ogg,.m4a" multiple />
</label>
</div>
<button class="button button--primary" id="add-source-button" type="button">+ 음원 추가</button>
</div>
<div class="source-strip" id="source-list" aria-label="음원 목록"></div>
<section class="asset-shelf">
<div>
<h2>로컬 assets</h2>
<p>원본 파일은 업로드되지 않습니다. Camera에서 JSON과 함께 같은 파일을 선택하세요.</p>
<p>원본은 업로드되지 않고 브라우저에서만 처리됩니다. PCM WAV를 권장하며, 다른 codec 지원은 환경에 따라 다릅니다.<br />Scene을 다시 불러온 뒤에는 위의 <strong>원본 오디오 연결</strong>에서 같은 파일을 선택하세요.</p>
</div>
<div class="asset-list" id="asset-list"></div>
</section>
@@ -127,6 +133,19 @@
<label class="field"><span>시작 (s)</span><input id="source-start" type="number" min="0" step="0.01" /></label>
<label class="field"><span>길이 (s)</span><input id="source-duration" type="number" min="0.01" step="0.01" /></label>
</div>
<section class="trajectory-editor" aria-label="음원 이동 경로">
<label class="check-field trajectory-toggle"><input id="source-motion" type="checkbox" /><span>시간에 따라 음원 이동</span></label>
<p class="section-note">경로 시간은 Scene 시작 기준입니다. 이동을 켜면 현재 위치에서 시작해 Scene 끝까지 이어지는 두 점 경로가 만들어집니다.</p>
<div id="trajectory-fields" class="is-hidden">
<label class="field field--wide trajectory-preview">
<span>경로 미리보기 · <output id="preview-time-value" for="preview-time">0.00 s</output></span>
<input id="preview-time" type="range" min="0" max="1" step="0.01" value="0" />
</label>
<p class="section-note">경로점을 선택하면 canvas 클릭으로 그 점의 x/y를 옮길 수 있습니다. 흰 테두리 점은 현재 선택한 경로점입니다.</p>
<div class="trajectory-list" id="trajectory-list"></div>
<button class="button button--ghost button--full" id="add-keyframe-button" type="button">+ 경로점 추가</button>
</div>
</section>
<label class="field field--wide"><span>신호 종류</span><select id="source-type"><option value="tone">Tone</option><option value="noise">Noise</option><option value="asset">Audio asset</option></select></label>
<div id="tone-fields"><label class="field field--wide"><span>주파수 (Hz)</span><input id="tone-frequency" type="number" min="1" step="1" /></label></div>
<div id="noise-fields"><label class="field field--wide"><span>Noise color</span><select id="noise-color"><option value="white">White</option><option value="pink">Pink</option></select></label></div>
+236 -22
View File
@@ -6,17 +6,26 @@ import { createAudioRuntime } from "./audio-runtime.js";
import { createSceneCanvas } from "./canvas-view.js";
import {
DEFAULT_VIEW_PLANE,
MAX_TRAJECTORY_KEYFRAMES,
SOURCE_COLORS,
assetDescriptorForFile,
changeSignalType,
createSceneId,
createSource,
describeSignal,
disableSourceTrajectory,
enableSourceTrajectory,
insertTrajectoryKeyframe,
isAssetReferenced,
matchAssetDescriptor,
prepareSceneForEditing,
removeTrajectoryKeyframe,
referencedAssetIds,
safeDownloadStem,
scaleSceneTrajectories,
synchronizeSourcePosition,
trajectoryTimeBounds,
updateTrajectoryKeyframeTime,
} from "./model.js";
const byId = (id) => {
@@ -27,13 +36,14 @@ const byId = (id) => {
const elements = Object.fromEntries(
[
"preview-button", "wav-button", "export-button", "image-input", "remove-image-button", "audio-input", "scene-input",
"preview-button", "wav-button", "export-button", "image-input", "remove-image-button", "audio-input", "audio-source-input", "scene-input",
"scene-canvas", "canvas-empty", "canvas-badge", "workspace-title", "source-list", "add-source-button",
"asset-list", "scene-name", "scene-id", "scene-duration", "sample-rate", "speed-of-sound", "scene-seed",
"mix-normalization", "target-peak", "plane-x-min", "plane-x-max", "plane-y-min", "plane-y-max",
"plane-z", "no-source-message", "source-fields", "source-name", "source-x", "source-y", "source-z",
"source-gain", "source-start", "source-duration", "source-type", "tone-fields", "tone-frequency",
"noise-fields", "noise-color", "asset-fields", "source-asset", "source-channel", "source-loop",
"source-motion", "trajectory-fields", "trajectory-list", "preview-time", "preview-time-value", "add-keyframe-button",
"remove-source-button", "ambient-type", "ambient-asset-field", "ambient-asset-options", "ambient-asset",
"ambient-channel", "ambient-loop", "ambient-gain",
"validation-dot", "validation-title", "validation-message", "save-state",
@@ -44,6 +54,8 @@ elements.validationCard = document.querySelector(".validation-card");
const state = {
scene: null,
selectedSourceId: null,
selectedKeyframeIndex: 0,
previewTime: 0,
viewPlane: { ...DEFAULT_VIEW_PLANE },
imageElement: null,
imageUrl: null,
@@ -70,8 +82,10 @@ const canvasView = createSceneCanvas(
({ x, y }) => {
const source = selectedSource();
if (!source) return;
source.position[0] = Number(x.toFixed(4));
source.position[1] = Number(y.toFixed(4));
const target = source.trajectory?.[state.selectedKeyframeIndex]?.position ?? source.position;
target[0] = Number(x.toFixed(4));
target[1] = Number(y.toFixed(4));
synchronizeSourcePosition(source);
syncSourceEditor();
markChanged("위치 변경됨");
renderSourceCards();
@@ -83,6 +97,22 @@ function selectedSource() {
return state.scene?.sources.find((source) => source.id === state.selectedSourceId) ?? null;
}
function sourceNameForAudioFile(file, fallback) {
const stem = file.name.replace(/\.[^.]+$/, "").trim();
return (stem || fallback).slice(0, 120);
}
function createAssetBackedSource(descriptor, file) {
if (state.scene.sources.length >= 32) return null;
const source = createSource(state.scene);
source.name = sourceNameForAudioFile(file, source.name);
changeSignalType(source, "asset", descriptor.id);
state.scene.sources.push(source);
state.selectedSourceId = source.id;
state.selectedKeyframeIndex = 0;
return source;
}
function numberFrom(element) {
return element.valueAsNumber;
}
@@ -122,7 +152,10 @@ function syncImagePlane() {
function renderCanvas() {
if (!state.scene) return;
canvasView.render(state.scene, state.imageElement, state.viewPlane, state.selectedSourceId);
canvasView.render(
state.scene, state.imageElement, state.viewPlane, state.selectedSourceId,
state.previewTime, state.selectedKeyframeIndex,
);
}
function renderSourceCards() {
@@ -144,10 +177,12 @@ function renderSourceCards() {
const signal = document.createElement("small");
signal.textContent = describeSignal(source.signal);
const position = document.createElement("small");
position.textContent = `x ${source.position[0].toFixed(2)} · y ${source.position[1].toFixed(2)} · z ${source.position[2].toFixed(2)} m`;
const motion = Array.isArray(source.trajectory) ? ` · 이동 ${source.trajectory.length}점` : "";
position.textContent = `x ${source.position[0].toFixed(2)} · y ${source.position[1].toFixed(2)} · z ${source.position[2].toFixed(2)} m${motion}`;
button.append(heading, signal, position);
button.addEventListener("click", () => {
state.selectedSourceId = source.id;
state.selectedKeyframeIndex = 0;
renderSourceCards();
syncSourceEditor();
renderCanvas();
@@ -178,6 +213,104 @@ function fillAudioSelect(select, selectedId, placeholder) {
select.value = selectedId ?? "";
}
function syncPreviewTime() {
const maximum = Math.max(0, Number(state.scene?.duration) || 0);
state.previewTime = Math.max(0, Math.min(maximum, state.previewTime));
elements["preview-time"].max = String(maximum);
elements["preview-time"].step = String(Math.max(0.001, Math.min(0.01, maximum / 100 || 0.01)));
elements["preview-time"].value = String(state.previewTime);
elements["preview-time-value"].value = `${state.previewTime.toFixed(2)} s`;
elements["preview-time-value"].textContent = `${state.previewTime.toFixed(2)} s`;
}
function trajectoryNumberField(label, value, options, onChange) {
const field = document.createElement("label");
field.className = "field";
const title = document.createElement("span");
title.textContent = label;
const input = document.createElement("input");
input.type = "number";
input.value = String(value);
input.step = String(options.step ?? 0.01);
if (Number.isFinite(options.min)) input.min = String(options.min);
if (Number.isFinite(options.max)) input.max = String(options.max);
input.addEventListener("change", () => {
if (!Number.isFinite(input.valueAsNumber)) {
input.value = String(value);
return;
}
onChange(input.valueAsNumber);
});
field.append(title, input);
return field;
}
function renderTrajectoryEditor(source) {
const moving = Array.isArray(source.trajectory);
elements["source-motion"].checked = moving;
elements["trajectory-fields"].classList.toggle("is-hidden", !moving);
syncPreviewTime();
const list = elements["trajectory-list"];
list.replaceChildren();
if (!moving) return;
state.selectedKeyframeIndex = Math.max(0, Math.min(source.trajectory.length - 1, state.selectedKeyframeIndex));
source.trajectory.forEach((keyframe, index) => {
const row = document.createElement("div");
row.className = "trajectory-row";
row.dataset.keyframeIndex = String(index);
const select = document.createElement("button");
select.type = "button";
select.className = "trajectory-row__select";
select.classList.toggle("is-selected", index === state.selectedKeyframeIndex);
select.textContent = String(index + 1);
select.title = `${index + 1}번 경로점 선택`;
select.addEventListener("click", () => {
state.selectedKeyframeIndex = index;
state.previewTime = keyframe.time;
renderTrajectoryEditor(source);
renderCanvas();
});
const bounds = trajectoryTimeBounds(source, index, state.scene.duration);
const time = trajectoryNumberField("t (s)", keyframe.time, { min: bounds.minimum, max: bounds.maximum }, (value) => {
state.previewTime = updateTrajectoryKeyframeTime(source, index, value, state.scene.duration);
syncSourceEditor();
renderCanvas();
markChanged("경로 시간 변경됨");
});
const coordinates = ["x", "y", "z"].map((axis, coordinateIndex) =>
trajectoryNumberField(`${axis} (m)`, keyframe.position[coordinateIndex], {
min: coordinateIndex === 2 ? 0.01 : undefined,
step: coordinateIndex === 2 ? 0.1 : 0.01,
}, (value) => {
keyframe.position[coordinateIndex] = value;
synchronizeSourcePosition(source);
state.previewTime = keyframe.time;
syncSourceEditor();
renderSourceCards();
renderCanvas();
markChanged("경로 위치 변경됨");
}),
);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "trajectory-row__remove";
remove.textContent = "×";
remove.title = "경로점 삭제";
remove.disabled = source.trajectory.length <= 2;
remove.addEventListener("click", () => {
if (!removeTrajectoryKeyframe(source, index)) return;
state.selectedKeyframeIndex = Math.min(state.selectedKeyframeIndex, source.trajectory.length - 1);
syncSourceEditor();
renderSourceCards();
renderCanvas();
markChanged("경로점 삭제됨");
});
row.append(select, time, ...coordinates, remove);
list.append(row);
});
elements["add-keyframe-button"].disabled = source.trajectory.length >= MAX_TRAJECTORY_KEYFRAMES;
}
function syncSourceEditor() {
const source = selectedSource();
elements["no-source-message"].classList.toggle("is-hidden", Boolean(source));
@@ -191,6 +324,7 @@ function syncSourceEditor() {
setNumber(elements["source-start"], source.startTime);
setNumber(elements["source-duration"], source.duration);
elements["source-type"].value = source.signal.type;
renderTrajectoryEditor(source);
elements["tone-fields"].classList.toggle("is-hidden", source.signal.type !== "tone");
elements["noise-fields"].classList.toggle("is-hidden", source.signal.type !== "noise");
elements["asset-fields"].classList.toggle("is-hidden", source.signal.type !== "asset");
@@ -517,7 +651,15 @@ function bindSceneControls() {
elements["workspace-title"].textContent = input.value || "이름 없는 장면";
});
bindInput("scene-id", (input) => { state.scene.id = input.value; });
bindInput("scene-duration", (input) => { state.scene.duration = numberFrom(input); });
bindInput("scene-duration", (input) => {
const previousDuration = state.scene.duration;
const nextDuration = numberFrom(input);
state.scene.duration = nextDuration;
scaleSceneTrajectories(state.scene, previousDuration, nextDuration);
syncPreviewTime();
syncSourceEditor();
renderCanvas();
});
bindInput("sample-rate", (input) => {
state.scene.sampleRate = Number(input.value);
state.decodedAudio.clear();
@@ -556,6 +698,7 @@ function bindSourceControls() {
const source = createSource(state.scene);
state.scene.sources.push(source);
state.selectedSourceId = source.id;
state.selectedKeyframeIndex = 0;
renderSourceCards();
syncSourceEditor();
renderCanvas();
@@ -568,6 +711,7 @@ function bindSourceControls() {
}
state.scene.sources = state.scene.sources.filter((source) => source.id !== state.selectedSourceId);
state.selectedSourceId = state.scene.sources[0].id;
state.selectedKeyframeIndex = 0;
renderSourceCards();
syncSourceEditor();
renderCanvas();
@@ -578,7 +722,12 @@ function bindSourceControls() {
for (const [id, index] of [["source-x", 0], ["source-y", 1], ["source-z", 2]]) {
bindInput(id, (input) => {
selectedSource().position[index] = numberFrom(input);
if (selectedSource().trajectory) {
selectedSource().trajectory[0].position[index] = numberFrom(input);
synchronizeSourcePosition(selectedSource());
}
renderSourceCards();
renderTrajectoryEditor(selectedSource());
renderCanvas();
});
}
@@ -596,6 +745,77 @@ function bindSourceControls() {
bindInput("source-asset", (input) => { selectedSource().signal.assetId = input.value; renderSourceCards(); }, "change");
bindInput("source-channel", (input) => { selectedSource().signal.channel = numberFrom(input); });
bindInput("source-loop", (input) => { selectedSource().signal.loop = input.checked; }, "change");
elements["source-motion"].addEventListener("change", () => {
const source = selectedSource();
if (elements["source-motion"].checked) {
enableSourceTrajectory(source, state.scene.duration, state.viewPlane);
state.selectedKeyframeIndex = 0;
state.previewTime = 0;
} else {
disableSourceTrajectory(source);
state.selectedKeyframeIndex = 0;
}
syncSourceEditor();
renderSourceCards();
renderCanvas();
markChanged(elements["source-motion"].checked ? "이동 경로 켜짐" : "이동 경로 꺼짐");
});
elements["preview-time"].addEventListener("input", () => {
state.previewTime = numberFrom(elements["preview-time"]);
syncPreviewTime();
renderCanvas();
});
elements["add-keyframe-button"].addEventListener("click", () => {
const source = selectedSource();
const insertedIndex = insertTrajectoryKeyframe(source);
if (insertedIndex < 0) {
setStatus(`경로점은 최대 ${MAX_TRAJECTORY_KEYFRAMES.toLocaleString()}개까지 추가할 수 있습니다.`);
return;
}
state.selectedKeyframeIndex = insertedIndex;
state.previewTime = source.trajectory[insertedIndex].time;
syncSourceEditor();
renderSourceCards();
renderCanvas();
markChanged("경로점 추가됨");
});
}
async function handleAudioFiles(files, { createSources = false } = {}) {
const epoch = state.assetEpoch;
let attachedCount = 0;
let createdCount = 0;
let lastError = null;
for (const file of files) {
if (epoch !== state.assetEpoch) return;
if (createSources && state.scene.sources.length >= 32) {
lastError = new Error("음원은 최대 32개까지 추가할 수 있습니다.");
break;
}
try {
const descriptor = await attachFile(file, "audio", epoch);
if (!descriptor) return;
attachedCount += 1;
if (createSources && createAssetBackedSource(descriptor, file)) createdCount += 1;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
}
}
if (epoch !== state.assetEpoch) return;
if (attachedCount > 0) {
renderAssetList();
renderSourceCards();
syncSourceEditor();
syncAmbientEditor();
renderCanvas();
const status = createSources
? `${createdCount}개 오디오 파일 음원 추가됨`
: `${attachedCount}개 원본 오디오 연결됨`;
markChanged(lastError ? `${status} · ${lastError.message}` : status);
} else if (lastError) {
setStatus(lastError.message);
}
}
function createAmbientSignal(type) {
@@ -704,22 +924,12 @@ function bindFileControls() {
elements["audio-input"].addEventListener("change", async (event) => {
const files = [...event.target.files];
event.target.value = "";
const epoch = state.assetEpoch;
for (const file of files) {
if (epoch !== state.assetEpoch) return;
try {
await attachFile(file, "audio", epoch);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error));
}
}
if (epoch !== state.assetEpoch) return;
if (files.length > 0) {
renderAssetList();
syncSourceEditor();
syncAmbientEditor();
markChanged(`${files.length}개 오디오 처리 완료`);
}
if (files.length > 0) await handleAudioFiles(files);
});
elements["audio-source-input"].addEventListener("change", async (event) => {
const files = [...event.target.files];
event.target.value = "";
if (files.length > 0) await handleAudioFiles(files, { createSources: true });
});
elements["scene-input"].addEventListener("change", async (event) => {
const [file] = event.target.files;
@@ -743,6 +953,8 @@ function bindFileControls() {
state.assetErrors.clear();
state.scene = prepareSceneForEditing(candidate);
state.selectedSourceId = state.scene.sources[0].id;
state.selectedKeyframeIndex = 0;
state.previewTime = 0;
state.viewPlane = state.scene.image
? {
xMin: state.scene.image.plane.xMin, xMax: state.scene.image.plane.xMax,
@@ -810,6 +1022,8 @@ async function initialize() {
state.scene = prepareSceneForEditing(await response.json());
state.scene.id = createSceneId();
state.selectedSourceId = state.scene.sources[0].id;
state.selectedKeyframeIndex = 0;
state.previewTime = 0;
if (state.scene.image) {
state.viewPlane = {
xMin: state.scene.image.plane.xMin, xMax: state.scene.image.plane.xMax,
+112
View File
@@ -7,11 +7,123 @@ export const DEFAULT_VIEW_PLANE = Object.freeze({
export const SOURCE_COLORS = ["#65f2bf", "#58c7e8", "#ffc56e", "#ff8f87", "#be9bff", "#8ee36b"];
export const MAX_TRAJECTORY_KEYFRAMES = 4096;
function copyPosition(position) {
return position.map((coordinate) => Number(coordinate));
}
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
export function synchronizeSourcePosition(source) {
if (Array.isArray(source.trajectory) && source.trajectory.length > 0) {
source.position = copyPosition(source.trajectory[0].position);
}
return source.position;
}
export function createDefaultTrajectory(source, sceneDuration, plane = DEFAULT_VIEW_PLANE) {
const start = copyPosition(source.position);
const width = Number.isFinite(plane?.xMax - plane?.xMin) && plane.xMax > plane.xMin
? plane.xMax - plane.xMin
: DEFAULT_VIEW_PLANE.xMax - DEFAULT_VIEW_PLANE.xMin;
const minimum = Number.isFinite(plane?.xMin) ? plane.xMin : DEFAULT_VIEW_PLANE.xMin;
const maximum = Number.isFinite(plane?.xMax) ? plane.xMax : DEFAULT_VIEW_PLANE.xMax;
const offset = Math.max(0.1, width * 0.25);
const rightRoom = maximum - start[0];
const leftRoom = start[0] - minimum;
const direction = rightRoom >= offset || rightRoom >= leftRoom ? 1 : -1;
const endX = clamp(start[0] + direction * offset, minimum, maximum);
return [
{ time: 0, position: start },
{ time: sceneDuration, position: [endX, start[1], start[2]] },
];
}
export function enableSourceTrajectory(source, sceneDuration, plane = DEFAULT_VIEW_PLANE) {
if (!Array.isArray(source.trajectory) || source.trajectory.length < 2) {
source.trajectory = createDefaultTrajectory(source, sceneDuration, plane);
}
synchronizeSourcePosition(source);
return source.trajectory;
}
export function disableSourceTrajectory(source) {
synchronizeSourcePosition(source);
delete source.trajectory;
}
export function insertTrajectoryKeyframe(source) {
if (!Array.isArray(source.trajectory) || source.trajectory.length < 2 ||
source.trajectory.length >= MAX_TRAJECTORY_KEYFRAMES) {
return -1;
}
let leftIndex = 0;
let largestGap = -Infinity;
for (let index = 0; index < source.trajectory.length - 1; index += 1) {
const gap = source.trajectory[index + 1].time - source.trajectory[index].time;
if (gap > largestGap) {
largestGap = gap;
leftIndex = index;
}
}
if (!(largestGap > 0)) return -1;
const left = source.trajectory[leftIndex];
const right = source.trajectory[leftIndex + 1];
source.trajectory.splice(leftIndex + 1, 0, {
time: left.time + largestGap / 2,
position: left.position.map((coordinate, index) =>
coordinate + (right.position[index] - coordinate) / 2,
),
});
synchronizeSourcePosition(source);
return leftIndex + 1;
}
export function removeTrajectoryKeyframe(source, index) {
if (!Array.isArray(source.trajectory) || source.trajectory.length <= 2 ||
!Number.isInteger(index) || index < 0 || index >= source.trajectory.length) return false;
source.trajectory.splice(index, 1);
synchronizeSourcePosition(source);
return true;
}
export function trajectoryTimeBounds(source, index, sceneDuration) {
const trajectory = source.trajectory;
const duration = Number.isFinite(sceneDuration) && sceneDuration > 0 ? sceneDuration : 0;
const gap = Math.max(1e-9, duration * 1e-9);
return {
minimum: index === 0 ? 0 : trajectory[index - 1].time + gap,
maximum: index === trajectory.length - 1 ? duration : trajectory[index + 1].time - gap,
};
}
export function updateTrajectoryKeyframeTime(source, index, requestedTime, sceneDuration) {
if (!Number.isFinite(requestedTime) || !source.trajectory?.[index]) return source.trajectory?.[index]?.time;
const { minimum, maximum } = trajectoryTimeBounds(source, index, sceneDuration);
source.trajectory[index].time = clamp(requestedTime, minimum, maximum);
return source.trajectory[index].time;
}
export function scaleSceneTrajectories(scene, previousDuration, nextDuration) {
if (!Number.isFinite(previousDuration) || previousDuration <= 0 ||
!Number.isFinite(nextDuration) || nextDuration <= 0) return;
const scale = nextDuration / previousDuration;
for (const source of scene.sources) {
if (!Array.isArray(source.trajectory)) continue;
source.trajectory.forEach((keyframe) => { keyframe.time *= scale; });
synchronizeSourcePosition(source);
}
}
export function prepareSceneForEditing(scene) {
const prepared = structuredClone(scene);
prepared.assets ??= [];
prepared.ambient ??= [];
prepared.mix ??= { normalization: "none", targetPeak: 0.95 };
prepared.sources.forEach(synchronizeSourcePosition);
return prepared;
}
+94 -1
View File
@@ -179,6 +179,14 @@
margin-top: 32px;
}
.source-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.source-strip-heading h2,
.asset-shelf h2 {
margin-bottom: 5px;
@@ -450,6 +458,76 @@
line-height: 1.5;
}
.trajectory-editor {
margin: 12px 0;
padding: 12px;
border: 1px solid rgb(101 242 191 / 16%);
border-radius: 12px;
background: rgb(101 242 191 / 3%);
}
.trajectory-toggle {
min-height: 28px;
}
.trajectory-preview {
margin-top: 12px;
}
.trajectory-preview output {
color: var(--mint);
font: 700 0.7rem ui-monospace, SFMono-Regular, Consolas, monospace;
}
.trajectory-preview input[type="range"] {
min-height: 28px;
padding: 0;
accent-color: var(--mint);
}
.trajectory-list {
display: grid;
gap: 8px;
margin: 12px 0 10px;
}
.trajectory-row {
display: grid;
grid-template-columns: 42px repeat(4, minmax(0, 1fr)) 30px;
gap: 5px;
align-items: end;
}
.trajectory-row__select,
.trajectory-row__remove {
min-height: 36px;
padding: 6px;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--muted);
background: #0b1714;
cursor: pointer;
}
.trajectory-row__select.is-selected {
border-color: var(--mint);
color: var(--mint);
}
.trajectory-row__remove:disabled {
cursor: not-allowed;
opacity: 0.3;
}
.trajectory-row .field > span {
font-size: 0.6rem;
}
.trajectory-row .field input {
min-height: 36px;
padding: 6px;
}
.is-hidden {
display: none !important;
}
@@ -524,10 +602,16 @@
}
.header-actions,
.workspace-tools {
.workspace-tools,
.source-actions {
width: 100%;
}
.source-actions .button {
min-width: 0;
flex: 1 1 calc(50% - 5px);
}
.scene-workspace {
padding: 18px;
}
@@ -549,4 +633,13 @@
.field-grid--three {
grid-template-columns: 1fr;
}
.trajectory-row {
grid-template-columns: 38px repeat(2, minmax(0, 1fr)) 30px;
}
.trajectory-row .field:nth-of-type(3),
.trajectory-row .field:nth-of-type(4) {
grid-row: 2;
}
}
+2
View File
@@ -6,6 +6,8 @@
- `single-tone.scene.json`: 48 kHz, 0.25 s, 1 kHz tone 하나를 z=2 m 평면에 배치한 Scene
- `single-tone.experiment.json`: rectangular 192채널 배열, 900–1100 Hz band, 50×50 scan,
FFT 512 / hop 256, Auto backend를 쓰는 Experiment
- `moving-tone.scene.json`: 2초 동안 다섯 trajectory keyframe을 따라 이동하는 2 kHz tone Scene
- `moving-tone.experiment.json`: 라이브 준비 시간을 줄인 24×18 scan, FFT 256 / hop 128 Experiment
두 문서는 계약 버전 `1.0.0`이며 기본 Camera 실행, CPU golden test, CPU/WebGPU 비교와 benchmark가
같은 조건을 공유하도록 고정되어 있습니다. fixture를 바꿀 때는 <http://127.0.0.1:4173/tests/>의
@@ -0,0 +1,29 @@
{
"schemaVersion": "1.0.0",
"kind": "micarrsim.experiment",
"id": "moving-tone-live",
"name": "2 kHz 이동 음원 · 라이브 미리보기",
"arrayId": "rectangular-16x12",
"sceneId": "moving-tone",
"scan": {
"z": 2,
"xMin": -0.6,
"xMax": 0.6,
"yMin": -0.45,
"yMax": 0.45,
"columns": 24,
"rows": 18
},
"frequencyBands": [
{ "id": "tone-2k", "minHz": 1850, "maxHz": 2150, "color": "#58c7ff" }
],
"stft": { "fftSize": 256, "hopSize": 128, "window": "hann" },
"evaluation": { "detectionThresholdRatio": 0.25 },
"beamforming": {
"algorithm": "das",
"backend": "auto",
"minimumDistance": 0.01,
"normalization": "peak",
"dynamicRangeDb": 30
}
}
@@ -0,0 +1,32 @@
{
"schemaVersion": "1.0.0",
"kind": "micarrsim.scene",
"id": "moving-tone",
"name": "이동하는 2 kHz 음원",
"description": "라이브 타임라인과 trajectory 편집을 바로 확인하는 자유음장 예제",
"units": "m",
"sampleRate": 8000,
"duration": 2,
"speedOfSound": 343,
"seed": 20260828,
"mix": { "normalization": "none", "targetPeak": 0.95 },
"sources": [
{
"id": "moving-source-1",
"name": "움직이는 2 kHz tone",
"position": [-0.45, -0.2, 2],
"trajectory": [
{ "time": 0, "position": [-0.45, -0.2, 2] },
{ "time": 0.5, "position": [-0.22, 0.18, 2] },
{ "time": 1, "position": [0, 0.3, 2] },
{ "time": 1.5, "position": [0.24, 0.12, 2] },
{ "time": 2, "position": [0.45, -0.2, 2] }
],
"gainDb": -6,
"startTime": 0,
"duration": 2,
"signal": { "type": "tone", "frequency": 2000, "phaseDeg": 0 }
}
],
"ambient": []
}
@@ -18,6 +18,7 @@
{ "id": "tone-1k", "minHz": 900, "maxHz": 1100, "color": "#65f2bf" }
],
"stft": { "fftSize": 512, "hopSize": 256, "window": "hann" },
"evaluation": { "detectionThresholdRatio": 0.25 },
"beamforming": {
"algorithm": "das",
"backend": "auto",
+20 -1
View File
@@ -34,6 +34,17 @@ Camera는 사용자가 다시 선택한 파일을 SHA-256으로 계산해 같은
Builder가 내보낸 Scene JSON과 사용한 원본 이미지·오디오는 함께 보관해야 한다. Preview WAV는
scene recipe를 mono로 합친 청취 확인용 파일이며 마이크 배열의 192채널 수신 신호가 아니다.
## 이동 음원 trajectory
Scene source의 선택적 `trajectory`는 2–4,096개의 `{ time, position }` keyframe 배열이다. `time`은
Scene 시작 기준 절대 초이며 0 이상 `scene.duration` 이하에서 엄격히 증가해야 한다. `position`은
정적 source와 같은 `[x, y, z]` m 좌표다. keyframe 사이 위치는 선형 보간하며, 첫 keyframe은
호환용 `source.position`과 같은 시작 위치로 Builder가 유지한다.
Camera 라이브 미리보기는 sample 경계에 맞춘 시간창 중앙에서 trajectory 위치를 평가한다. 짧은
창으로 Scene 시간을 다시 기준화해도 tone 위상, noise sample index와 audio asset 재생 위치는 원래
절대 시간에 이어진다. 이는 준정적 근사이며 Doppler를 계약하지 않는다.
## 오디오와 map 정규화
`scene.mix.normalization`은 마이크 신호 합성 전에 적용할 오디오 mix 정책이다. `none`은 원래
@@ -47,10 +58,18 @@ raw DAS map을 덮어쓰지 않는다.
map은 row-major 배열이다. 첫 행은 `yMax`, 마지막 행은 `yMin`, 각 행은 `xMin`에서 `xMax`
방향이다. `values.length`는 반드시 `rows × columns`와 같아야 한다.
Experiment의 선택적 `evaluation.detectionThresholdRatio`는 최강 local peak power에 대한 검출
임계 비율이며 `0 < ratio <= 1`이다. 생략된 이전 문서를 Camera에서 열면 기본값 `0.25`를 적용하며,
Experiment JSON을 다시 저장하면 이 값이 명시된다. 값을 낮추면 약한 peak 후보를 포함하지만
sidelobe false positive도 늘 수 있다.
## Result 평가 정보
각 band의 `peaks`는 위치와 power 값, 선택적 `sourceId`, `localizationErrorM`,
`localizationErrorPixels`를 저장한다. `metrics`에는 다음 유한값만 기록한다.
`localizationErrorPixels`, `beamSpread`를 저장한다. `beamSpread`는 선택 peak를 포함하는 연결된
half-power 성분의 power 가중 centroid·공분산과 major/minor RMS 반경·각도다. `calibrated`는 현재
항상 `false`이며 확률분포, Gaussian 신뢰구간 또는 통계적 위치 불확실성으로 해석하지 않는다.
`metrics`에는 다음 유한값만 기록한다.
- `peakToSidelobeDb`
- `energyInsideTruthRatio`
+15 -3
View File
@@ -3,7 +3,8 @@
> 구현 상태 · 2026-08-28
>
> 단계 0–4는 완료되어 설치 없는 브라우저 앱, CPU/WebGPU DAS, Builder→Camera asset 재연결,
> 배열 비교, 평가·Result 내보내기와 53개 자동 테스트로 검증했다. 단계 5는 향후 고도화 범위다.
> 임의 오디오·이동 trajectory, 빔 분포·복수 peak 감도, 라이브 시간축 재생, 배열 비교와
> Result 내보내기를 71개 자동 테스트로 검증했다. 단계 5는 실제 장치·공간 모델 고도화 범위다.
## 1. 목표와 성공 기준
@@ -133,6 +134,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
### 단계 2 — Dataset Builder · 완료
- 이미지와 음원 파일 불러오기, 위치·시간·gain 편집, ambient mix와 미리 듣기
- 파일 선택 한 번으로 audio asset source 생성, source별 trajectory keyframe 편집·미리보기
- deterministic seed, clipping 경고, Scene JSON과 mono Preview WAV 내보내기
- synthetic tone/noise fixture와 로컬 asset manifest 경계 구현
@@ -151,6 +153,8 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
- scene/ground truth/acoustic overlay, 배열 geometry, parameter panel 구현
- 결과 metric, 실행 환경, 입력 hash와 map 내보내기
- 복수 음원·다중 대역 표시와 실험 preset 비교
- peak별 half-power 공분산/RMS 빔 분포와 재현 가능한 검출 임계값
- sample-aligned 준정적 시간창 분석, bounded map 계획과 이동 장면 재생
완료 조건: 사용자가 코드 수정 없이 배열 JSON 두 개를 같은 장면에서 비교할 수 있다.
@@ -158,6 +162,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
- 라이선스 검토를 통과한 DCASE/MIMII adapter와 batch workflow 추가
- microphone calibration·noise 모델 검토
- 실제 192채널 장치의 channel/clock/sample 형식이 정해진 뒤 capture·stream adapter 추가
- 필요 시 DAMAS/CLEAN-SC를 플러그인형 후처리로 추가
- image-source method 또는 room impulse response 기반 잔향 모듈 추가
@@ -167,8 +172,9 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
- WebGPU 미지원/장치 한도: 시작 시 capability를 표시하고 CPU fallback 및 grid 상한을 둔다.
- 좌표·phase 부호 오류: analytic tone test와 배열 대칭성 test를 먼저 만든다.
- 브라우저 메모리: 현재 전체 192채널 signal buffer에 512 MiB 한도를 적용한다. 장시간 입력은
향후 chunk/STFT streaming backend로 확장한다.
- 브라우저 메모리: snapshot은 전체 192채널 signal buffer에 512 MiB 한도를 적용하고, 이동
미리보기는 최대 48개·2,500 points의 짧은 시간창으로 제한한다. 실제 장시간 capture는 향후
chunk/STFT streaming backend로 확장한다.
- 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로
쓴다. 실제 데이터 adapter는 단계 5의 라이선스 검토 뒤 추가한다.
- 실제 AC와 simulation gap: 감도, 위상, 위치 오차를 이후 calibration/noise model로 주입 가능하게 한다.
@@ -183,7 +189,13 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
- [x] WGSL kernel, CPU/GPU 비교와 50²/100²/200² benchmark
- [x] scene canvas, array view, parameter controls와 Result export
- [x] Builder→Camera SHA-256 asset 재연결 왕복 테스트
- [x] 임의 오디오 source 원클릭 생성과 codec 안내
- [x] 이동 trajectory 계약·편집·시간창 합성·Camera 라이브 재생
- [x] 비확률 beam spread RMS contour와 Result export
- [x] 복수 음원용 Experiment peak 검출 감도
- [x] `test.cmd` one-command headless browser 검증
- [ ] DCASE/MIMII 라이선스 검토와 adapter
- [ ] 실제 192채널 capture/stream adapter
- [ ] 잔향·calibration·noise model
- [ ] DAMAS/CLEAN-SC 후처리
+45 -8
View File
@@ -16,6 +16,7 @@
<a class="guide-brand" href="/">← micarrsim 홈</a>
<nav aria-label="문서 목차">
<a href="#quick-start">빠른 시작</a>
<a href="#moving-live">이동 라이브</a>
<a href="#full-workflow">내 데이터</a>
<a href="#results">결과 해석</a>
<a href="#troubleshooting">문제 해결</a>
@@ -52,16 +53,34 @@
</div>
</section>
<section class="guide-section" id="full-workflow">
<section class="guide-section" id="moving-live">
<div class="section-number">02</div>
<div>
<p class="eyebrow">Moving-source demo</p>
<h2>이동 음원을 바로 재생하기</h2>
<ol class="guide-steps">
<li><a href="/apps/acoustic-camera/">Acoustic Camera</a>에서 <strong>이동 예제</strong>를 누릅니다.</li>
<li><strong>라이브 준비</strong>를 눌러 기본 2 Hz map을 계산합니다.</li>
<li><strong>재생</strong>을 누르거나 시간 slider를 움직입니다.</li>
</ol>
<aside class="callout">
짧은 시간창마다 계산한 heatmap과 trajectory 위치를 보간하는 합성 장면 미리보기입니다.
실제 192채널 마이크 스트림을 녹음하는 기능은 아닙니다.
</aside>
</div>
</section>
<section class="guide-section" id="full-workflow">
<div class="section-number">03</div>
<div>
<p class="eyebrow">5분 전체 흐름</p>
<h2>내 장면을 만들고 Camera에서 실행하기</h2>
<ol class="guide-steps">
<li>
<a href="/apps/dataset-builder/">Dataset Builder</a>에서 이미지와 오디오를
선택하고 음원 위치, gain, 시간, ambient를 설정합니다.
<a href="/apps/dataset-builder/">Dataset Builder</a>에서 <strong>+ 오디오 파일 음원</strong>
또는 합성 음원을 추가하고 위치, gain, 시간과 ambient를 설정합니다.
</li>
<li>움직이는 source는 <strong>시간에 따라 음원 이동</strong>을 켜고 keyframe을 편집합니다.</li>
<li><strong>Scene JSON 저장</strong>을 누르고 사용한 원본 파일과 함께 보관합니다.</li>
<li><strong>Scene 저장 후 Camera 열기</strong>를 누릅니다.</li>
<li>
@@ -91,7 +110,7 @@
</section>
<section class="guide-section">
<div class="section-number">03</div>
<div class="section-number">04</div>
<div>
<p class="eyebrow">Compute backend</p>
<h2>속도와 격자 선택</h2>
@@ -109,23 +128,33 @@
먼저 50×50을 권장합니다. 100×100은 4배, 200×200은 16배의 scan point를 계산합니다.
실행 중에는 취소할 수 있으며, 취소하거나 실패해도 이전 완료 결과는 유지됩니다.
</p>
<p class="guide-copy">
복수 음원에서 약한 peak가 빠지면 <strong>Peak 검출 임계값</strong>을 기본 25%에서
10–20%로 낮춰 보세요. 약한 후보와 함께 sidelobe 오검출도 늘 수 있으며, main lobe보다
가까운 같은 대역 음원은 conventional DAS에서 하나로 합쳐질 수 있습니다.
</p>
</div>
</section>
<section class="guide-section" id="results">
<div class="section-number">04</div>
<div class="section-number">05</div>
<div>
<p class="eyebrow">Result JSON</p>
<h2>무엇이 저장되나요?</h2>
<dl class="result-list">
<div><dt>rawMap / displayMap</dt><dd>선형 power 원본과 화면 표시용 acoustic map</dd></div>
<div><dt>peaks</dt><dd>검출 위치, source 매칭, m·pixel 위치 오차</dd></div>
<div><dt>beamSpread</dt><dd>half-power lobe의 power 가중 공분산과 1× RMS ellipse</dd></div>
<div><dt>metrics</dt><dd>energy 비율, precision/recall, sidelobe, main-lobe 폭</dd></div>
<div><dt>evaluation</dt><dd>검출 threshold, 반경, tolerance 등 지표 계산 조건</dd></div>
<div><dt>timing / environment</dt><dd>backend, grid·scan 범위, 장치 정보와 구간별 시간</dd></div>
<div><dt>inputHash</dt><dd>Array·Scene·Experiment 입력을 묶은 SHA-256 재현성 식별자</dd></div>
<div><dt>comparison-history</dt><dd>세션의 배열별 시간·위치 오차·PSR 요약을 한 번에 저장</dd></div>
</dl>
<aside class="callout">
화면의 <strong>빔 분포</strong> 1×/2× RMS contour는 acoustic power lobe의 모양입니다.
위치 확률, Gaussian 신뢰구간 또는 보정된 측정 불확실성이 아닙니다.
</aside>
<aside class="callout">
평가는 tone 음원을 해당 주파수 대역이 tone 주파수를 포함할 때만 정답으로 사용합니다.
noise와 asset 음원은 현재 모든 대역의 정답으로 간주합니다. 녹음 파일의 실제 대역별
@@ -140,7 +169,7 @@
</section>
<section class="guide-section" id="troubleshooting">
<div class="section-number">05</div>
<div class="section-number">06</div>
<div>
<p class="eyebrow">Troubleshooting</p>
<h2>막혔을 때 확인할 것</h2>
@@ -164,18 +193,26 @@
<summary>WebGPU가 없거나 실행이 너무 느립니다.</summary>
<p>Backend를 Auto 또는 CPU로 두고 50×50 grid부터 실행하세요. 최신 Chrome/Edge와 그래픽 드라이버도 확인하세요.</p>
</details>
<details>
<summary>두 번째 음원을 찾지 못합니다.</summary>
<p>두 음원이 같은 주파수 대역인지 확인하고 Peak 검출 임계값을 낮추세요. gain 차이, main-lobe 폭보다 짧은 거리, grid 간격과 sidelobe는 threshold만으로 해결되지 않을 수 있습니다.</p>
</details>
<details>
<summary>라이브 준비 버튼이 비활성화됩니다.</summary>
<p>Scene에 시간 순서가 올바른 trajectory keyframe이 두 개 이상 있어야 합니다. Builder에서 시간에 따라 음원 이동을 켜거나 Camera의 이동 예제를 사용하세요.</p>
</details>
</div>
</section>
<section class="guide-section">
<div class="section-number">06</div>
<div class="section-number">07</div>
<div>
<p class="eyebrow">Scope & privacy</p>
<h2>현재 모델의 범위</h2>
<p class="guide-copy">
micarrsim 1.0은 점 음원, 구면 감쇠·분수 지연을 쓰는 자유음장 모델과 conventional
DAS를 제공합니다. 실내 반사·잔향, 실제 마이크 위치 오차와 비선형성, DAMAS,
CLEAN-SC, DCASE/MIMII 자동 adapter는 현재 범위에 포함되지 않습니다. 결과는 실제
CLEAN-SC, DCASE/MIMII 자동 adapter와 실제 192채널 capture stream은 현재 범위에 포함되지 않습니다. 결과는 실제
장비의 절대 성능 보증이 아니라 같은 조건에서 배열을 비교하는 재현 가능한 기준입니다.
</p>
<p class="guide-copy">
+7 -1
View File
@@ -59,7 +59,7 @@
<span class="launcher-card__number">02</span>
<div>
<h2>Acoustic Camera</h2>
<p>배열 응답을 계산하고 주파수 대역별 DAS 결과를 ground truth와 비교합니다.</p>
<p>배열 응답과 DAS 결과를 비교하고 이동 음원의 acoustic map을 시간축으로 재생합니다.</p>
</div>
<span class="launcher-card__action">기본 예제로 바로 실행 →</span>
</a>
@@ -72,6 +72,12 @@
<p>Acoustic Camera를 열어 <strong>▶ 기본 예제 실행</strong>을 누르고, 완료되면 <strong>Result JSON</strong>을 저장하세요.</p>
<a href="/apps/acoustic-camera/">기본 예제 열기 →</a>
</article>
<article class="quick-start-card">
<p class="eyebrow">이동 음원</p>
<h2>설정 없이 라이브 재생</h2>
<p>Camera에서 <strong>이동 예제 → 라이브 준비 → 재생</strong>만 누르면 됩니다.</p>
<a href="/apps/acoustic-camera/">이동 예제 열기 →</a>
</article>
<article class="quick-start-card">
<p class="eyebrow">내 데이터</p>
<h2>Builder부터 끝까지</h2>
+16
View File
@@ -8,6 +8,22 @@
- radix-2 FFT, Hann STFT와 선택 bin 추출
- scan grid 거리 cache 및 conventional frequency-domain DAS
## 이동 음원과 분석 시간창
`sourcePositionAtTime`은 scene 시작 기준의 절대 시각에서 trajectory keyframe 사이를 선형 보간하며,
경로 범위 밖에서는 가장 가까운 끝점 위치를 유지합니다. `sampleQuasiStaticWindow`는 요청 경계를
sample 경계로 올림 정렬하고, 시간창 중앙의 위치에서 이동 음원을 정지한 것으로 근사합니다.
시간창은 활성 source만 잘라 짧은 Scene으로 만들지만 `signalOffsets`에 원래 scene/source의 절대 sample
offset을 보존합니다. 이를 `synthesizeMicrophoneSignals`에 전달하면 각 창을 따로 합성해도 tone phase,
seed 기반 noise index, audio asset 재생 위치와 ambient가 전체 timeline 합성과 연속됩니다.
## Beam spread 의미
Peak의 `beamSpread`는 half-power 연결 성분을 power 가중한 공간 covariance(`covarianceM2`, m²)와 그
RMS 주축 ellipse로 요약한 빔 폭입니다. 가우시안 확률 모델, 위치 확률 또는 신뢰구간으로 보정된 값이
아니며 `calibrated: false`를 유지합니다.
FFT는 `X[k] = Σ x[n] exp(-j2πkn/N)` 규약을 사용합니다. 마이크 신호의 전파 phase
`exp(-j2πfr/c)`를 정렬하기 위해 DAS steering은 `exp(+j2πfr/c)`를 곱합니다. 단일 tone의
ground-truth 위치에서 peak가 생기는 회귀 테스트로 이 부호를 고정합니다.
+141 -1
View File
@@ -386,6 +386,129 @@ export function measureMainLobe(rawMap, scan, peakIndex, halfPowerRatio = 0.5) {
return { peakIndex, thresholdValue, pixelCount: queueEnd, widthXM, widthYM, equivalentDiameterM };
}
function collectHalfPowerIndices(values, scan, peakIndex, halfPowerRatio) {
const peakValue = values[peakIndex];
if (!(peakValue > 0)) return { indices: new Int32Array(0), thresholdValue: 0 };
const thresholdValue = peakValue * halfPowerRatio;
const visited = new Uint8Array(values.length);
const queue = new Int32Array(values.length);
let queueStart = 0;
let queueEnd = 1;
queue[0] = peakIndex;
visited[peakIndex] = 1;
while (queueStart < queueEnd) {
const index = queue[queueStart];
queueStart += 1;
const row = Math.floor(index / scan.columns);
const column = index % scan.columns;
for (let neighborRow = Math.max(0, row - 1); neighborRow <= Math.min(scan.rows - 1, row + 1); neighborRow += 1) {
for (
let neighborColumn = Math.max(0, column - 1);
neighborColumn <= Math.min(scan.columns - 1, column + 1);
neighborColumn += 1
) {
const neighborIndex = neighborRow * scan.columns + neighborColumn;
if (!visited[neighborIndex] && values[neighborIndex] >= thresholdValue) {
visited[neighborIndex] = 1;
queue[queueEnd] = neighborIndex;
queueEnd += 1;
}
}
}
}
return { indices: queue.subarray(0, queueEnd), thresholdValue };
}
function measureBeamSpreadFromValues(values, scan, peakIndex, halfPowerRatio) {
const component = collectHalfPowerIndices(values, scan, peakIndex, halfPowerRatio);
const peakPosition = scanIndexToPosition(peakIndex, scan);
const base = {
calibrated: false,
method: "connected-half-power-power-weighted-covariance",
halfPowerRatio,
thresholdValue: component.thresholdValue,
pixelCount: component.indices.length,
centroid: [...peakPosition],
covarianceM2: { xx: 0, xy: 0, yy: 0 },
ellipse: {
axisScale: "rms",
angleReference: "counterclockwise-from-positive-x",
majorRmsRadiusM: 0,
minorRmsRadiusM: 0,
angleDeg: 0,
},
};
if (component.indices.length === 0) return base;
// Normalizing by the component maximum changes no weighted moments and
// prevents a sum of otherwise finite power values from overflowing.
let maximum = 0;
for (const index of component.indices) maximum = Math.max(maximum, values[index]);
let totalWeight = 0;
let weightedX = 0;
let weightedY = 0;
for (const index of component.indices) {
const weight = values[index] / maximum;
const position = scanIndexToPosition(index, scan);
totalWeight += weight;
weightedX += weight * position[0];
weightedY += weight * position[1];
}
const centroidX = scan.columns === 1 ? scan.xMin : weightedX / totalWeight;
const centroidY = scan.rows === 1 ? scan.yMax : weightedY / totalWeight;
let xx = 0;
let xy = 0;
let yy = 0;
for (const index of component.indices) {
const weight = values[index] / maximum;
const position = scanIndexToPosition(index, scan);
const x = position[0] - centroidX;
const y = position[1] - centroidY;
xx += weight * x * x;
xy += weight * x * y;
yy += weight * y * y;
}
xx = scan.columns === 1 ? 0 : Math.max(0, xx / totalWeight);
xy = scan.columns === 1 || scan.rows === 1 ? 0 : xy / totalWeight;
yy = scan.rows === 1 ? 0 : Math.max(0, yy / totalWeight);
const trace = xx + yy;
const discriminant = Math.hypot(xx - yy, 2 * xy);
const majorVariance = Math.max(0, (trace + discriminant) / 2);
const minorVariance = Math.max(0, (trace - discriminant) / 2);
const covarianceScale = Math.max(Math.abs(xx), Math.abs(xy), Math.abs(yy));
const directionDefined = covarianceScale > 0 && discriminant > Number.EPSILON * covarianceScale * 8;
let angleDeg = directionDefined ? (Math.atan2(2 * xy, xx - yy) * 90) / Math.PI : 0;
if (Object.is(angleDeg, -0)) angleDeg = 0;
return {
...base,
centroid: [centroidX, centroidY, scan.z],
covarianceM2: { xx, xy, yy },
ellipse: {
...base.ellipse,
majorRmsRadiusM: Math.sqrt(majorVariance),
minorRmsRadiusM: Math.sqrt(minorVariance),
angleDeg,
},
};
}
/**
* Fit a deterministic, power-weighted covariance ellipse to the connected
* half-power lobe. This describes beam spread, not probability or confidence.
*/
export function measureBeamSpread(rawMap, scan, peakIndex, halfPowerRatio = 0.5) {
validateScan(scan);
const values = normalizeRawMap(rawMap, scan);
if (!Number.isInteger(peakIndex) || peakIndex < 0 || peakIndex >= values.length) {
throw new RangeError("peakIndex is outside rawMap.");
}
requireRatio("halfPowerRatio", halfPowerRatio, { positive: true });
return measureBeamSpreadFromValues(values, scan, peakIndex, halfPowerRatio);
}
/**
* Evaluate one linear-power DAS band. Source z coordinates are projected onto
* scan.z, matching the image-plane ground-truth convention.
@@ -432,7 +555,12 @@ export function evaluateDasBand(options) {
const localPeaks = findLocalPeaks(values, scan);
const globalPeak = localPeaks.length > 0 ? { ...localPeaks[0], position: [...localPeaks[0].position] } : null;
const thresholdValue = (globalPeak?.value ?? 0) * detectionThresholdRatio;
const detections = selectDetections(localPeaks, thresholdValue, minimumPeakDistanceM, maximumDetections);
const detections = selectDetections(localPeaks, thresholdValue, minimumPeakDistanceM, maximumDetections).map(
(detection) => ({
...detection,
beamSpread: measureBeamSpreadFromValues(values, scan, detection.index, halfPowerRatio),
}),
);
const { sourceMatches, truePositiveCount } = matchTruths(truths, detections, matchToleranceM);
const precision = detections.length > 0 ? truePositiveCount / detections.length : truths.length === 0 ? 1 : 0;
const recall = truths.length > 0 ? truePositiveCount / truths.length : 1;
@@ -513,6 +641,18 @@ export function exportBandEvaluation(evaluation, options = {}) {
}
const peaks = (evaluation.detections ?? []).slice(0, maximumPeaks).map((peak) => {
const resultPeak = { position: [...peak.position], value: peak.value };
if (peak.beamSpread) {
resultPeak.beamSpread = {
calibrated: peak.beamSpread.calibrated,
method: peak.beamSpread.method,
halfPowerRatio: peak.beamSpread.halfPowerRatio,
thresholdValue: peak.beamSpread.thresholdValue,
pixelCount: peak.beamSpread.pixelCount,
centroid: [...peak.beamSpread.centroid],
covarianceM2: { ...peak.beamSpread.covarianceM2 },
ellipse: { ...peak.beamSpread.ellipse },
};
}
const match = matchByPeakIndex.get(peak.index);
if (match) {
resultPeak.sourceId = match.sourceId;
+1
View File
@@ -8,4 +8,5 @@ export * from "./noise.js";
export * from "./propagation.js";
export * from "./signal.js";
export * from "./stft.js";
export * from "./trajectory.js";
export * from "./wav.js";
+11 -3
View File
@@ -23,6 +23,7 @@ export function synthesizeMicrophoneSignals(options) {
referenceDistance = 1,
minimumDistance = 0.01,
maximumBufferBytes = 512 * 1024 * 1024,
signalOffsets,
} = options;
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
const retainedBufferCount = arrayDefinition.microphones.length + scene.sources.length + 1;
@@ -46,9 +47,13 @@ export function synthesizeMicrophoneSignals(options) {
);
}
const channels = arrayDefinition.microphones.map(() => new Float32Array(sampleCount));
const renderedSources = scene.sources.map((source) => ({
const sceneSampleOffset = signalOffsets?.sceneSampleOffset ?? 0;
const renderedSources = scene.sources.map((source, sourceIndex) => ({
definition: source,
samples: renderSignal(source, scene, assetBuffers),
samples: renderSignal(source, scene, assetBuffers, 0, {
sceneSampleOffset,
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
}),
}));
renderedSources.forEach(({ definition, samples }) => {
@@ -77,7 +82,10 @@ export function synthesizeMicrophoneSignals(options) {
channels.forEach((channel, microphoneIndex) => {
const microphone = arrayDefinition.microphones[microphoneIndex];
const seedSalt = Math.imul(ambientIndex + 1, 0x45d9f3b) ^ Math.imul(microphoneIndex + 1, 0x27d4eb2d);
const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt);
const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt, {
sceneSampleOffset,
...(signalOffsets?.ambient?.[ambientIndex] ?? {}),
});
const spectrum = prepareDelaySpectrum(ambientSamples, 0);
addFrequencyDomainDelay({
output: channel,
+25 -3
View File
@@ -15,7 +15,16 @@ function sampleAsset(signal, assetBuffers, time) {
return channel[left] + (rightValue - channel[left]) * fraction;
}
export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt = 0) {
/**
* Render one signal entry into the scene-sized output buffer.
*
* `timeMapping` lets a short, rebased scene window retain the original signal
* timeline. `sceneSampleOffset` maps output sample zero to an absolute sample
* in the original scene, while `signalStartTime`/`signalStartSample` preserve
* the entry's original tone/asset time origin and deterministic noise index.
* Omitting it keeps the historical whole-scene behaviour exactly unchanged.
*/
export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt = 0, timeMapping = {}) {
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
const output = new Float32Array(sampleCount);
const startTime = entry.startTime ?? 0;
@@ -23,12 +32,25 @@ export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt =
const gain = dbToLinear(entry.gainDb ?? 0);
const startSample = Math.max(0, Math.ceil(startTime * scene.sampleRate));
const endSample = Math.min(sampleCount, Math.ceil((startTime + duration) * scene.sampleRate));
const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0;
const signalStartTime = timeMapping.signalStartTime ?? startTime;
const signalStartSample = timeMapping.signalStartSample ?? Math.ceil(startTime * scene.sampleRate);
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
}
if (!Number.isFinite(signalStartTime)) {
throw new TypeError("signalStartTime must be finite.");
}
if (!Number.isSafeInteger(signalStartSample) || signalStartSample < 0) {
throw new RangeError("signalStartSample must be a non-negative safe integer.");
}
const signal = entry.signal;
const seed = (scene.seed + (signal.seedOffset ?? 0) + hashString(entry.id ?? "signal") + seedSalt) >>> 0;
for (let sampleIndex = startSample; sampleIndex < endSample; sampleIndex += 1) {
const localIndex = sampleIndex - startSample;
const localTime = sampleIndex / scene.sampleRate - startTime;
const absoluteSampleIndex = sceneSampleOffset + sampleIndex;
const localIndex = absoluteSampleIndex - signalStartSample;
const localTime = absoluteSampleIndex / scene.sampleRate - signalStartTime;
let value;
if (signal.type === "tone") {
value = Math.sin(TAU * signal.frequency * localTime + ((signal.phaseDeg ?? 0) * Math.PI) / 180);
+176
View File
@@ -0,0 +1,176 @@
function copyPosition(position) {
if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) {
throw new TypeError("source position must be a finite [x, y, z] array.");
}
return [position[0], position[1], position[2]];
}
function requireFiniteTime(time, label = "time") {
if (!Number.isFinite(time)) throw new TypeError(label + " must be finite.");
}
/**
* Resolve a source position at an absolute time measured from the beginning of
* the scene. Valid trajectories are ordered by the scene contract. Values
* outside their covered interval hold the nearest endpoint position.
*/
export function sourcePositionAtTime(source, time) {
requireFiniteTime(time);
const trajectory = source?.trajectory;
if (!Array.isArray(trajectory) || trajectory.length === 0) {
return copyPosition(source?.position);
}
const first = trajectory[0];
const last = trajectory[trajectory.length - 1];
if (time <= first.time) return copyPosition(first.position);
if (time >= last.time) return copyPosition(last.position);
let low = 0;
let high = trajectory.length - 1;
while (high - low > 1) {
const middle = (low + high) >> 1;
if (trajectory[middle].time <= time) low = middle;
else high = middle;
}
const left = trajectory[low];
const right = trajectory[high];
const ratio = (time - left.time) / (right.time - left.time);
return left.position.map((coordinate, index) =>
coordinate + (right.position[index] - coordinate) * ratio,
);
}
/**
* Return a new shallow scene/source graph whose source positions are resolved
* at one instant. Trajectories stay attached so callers can sample the returned
* scene again; the input scene and source objects are never mutated.
*/
export function sceneAtTime(scene, time) {
requireFiniteTime(time);
if (!scene || !Array.isArray(scene.sources)) {
throw new TypeError("scene.sources must be an array.");
}
return {
...scene,
sources: scene.sources.map((source) => ({
...source,
position: sourcePositionAtTime(source, time),
})),
};
}
/**
* Approximate a moving scene as stationary during [startTime, endTime] by
* sampling every trajectory at the window midpoint.
*/
export function sampleQuasiStaticWindow(scene, startTime, endTime) {
requireFiniteTime(startTime, "startTime");
requireFiniteTime(endTime, "endTime");
if (startTime < 0 || endTime <= startTime) {
throw new RangeError("window must satisfy 0 <= startTime < endTime.");
}
if (Number.isFinite(scene?.duration) && endTime > scene.duration) {
throw new RangeError("window endTime must not exceed scene.duration.");
}
const sampleRate = scene?.sampleRate;
if (!Number.isInteger(sampleRate) || sampleRate <= 0) {
const sampleTime = startTime + (endTime - startTime) / 2;
return {
startTime,
endTime,
sampleTime,
duration: endTime - startTime,
scene: sceneAtTime(scene, sampleTime),
};
}
// Ceil selects the first sample whose timestamp is inside each half-open
// boundary. Adjacent windows therefore share an exact boundary without a
// duplicated or omitted sample, even when their requested times are not
// integer multiples of the sample period.
const startSample = Math.ceil(startTime * sampleRate);
const endSample = Math.ceil(endTime * sampleRate);
if (endSample <= startSample) {
throw new RangeError("window must contain at least one sample.");
}
const alignedStartTime = startSample / sampleRate;
const alignedEndTime = endSample / sampleRate;
const sampleTime = (startSample + endSample) / (2 * sampleRate);
const sources = [];
const sourceOffsets = [];
for (const source of scene.sources) {
const signalStartTime = source.startTime ?? 0;
const signalDuration = source.duration ?? scene.duration;
const signalStartSample = Math.ceil(signalStartTime * sampleRate);
const signalEndSample = Math.ceil((signalStartTime + signalDuration) * sampleRate);
const activeStartSample = Math.max(startSample, signalStartSample);
const activeEndSample = Math.min(endSample, signalEndSample);
if (activeEndSample <= activeStartSample) continue;
sources.push({
...source,
position: sourcePositionAtTime(source, sampleTime),
startTime: (activeStartSample - startSample) / sampleRate,
duration: (activeEndSample - activeStartSample) / sampleRate,
});
sourceOffsets.push({ signalStartTime, signalStartSample });
}
const ambient = Array.isArray(scene.ambient) ? scene.ambient.slice() : scene.ambient;
const signalOffsets = {
sceneSampleOffset: startSample,
sources: sourceOffsets,
ambient: (scene.ambient ?? []).map(() => ({ signalStartTime: 0, signalStartSample: 0 })),
};
const duration = (endSample - startSample) / sampleRate;
return {
startTime: alignedStartTime,
endTime: alignedEndTime,
sampleTime,
startSample,
endSample,
sampleCount: endSample - startSample,
duration,
signalOffsets,
scene: {
...scene,
duration,
sources,
...(Array.isArray(scene.ambient) ? { ambient } : {}),
},
};
}
/**
* Split a scene interval into quasi-static windows. The final window is
* shortened to the requested end time instead of being dropped.
*/
export function createQuasiStaticWindows(scene, options = {}) {
const startTime = options.startTime ?? 0;
const endTime = options.endTime ?? scene?.duration;
const windowDuration = options.windowDuration;
const hopDuration = options.hopDuration ?? windowDuration;
requireFiniteTime(startTime, "startTime");
requireFiniteTime(endTime, "endTime");
requireFiniteTime(windowDuration, "windowDuration");
requireFiniteTime(hopDuration, "hopDuration");
if (startTime < 0 || endTime <= startTime) {
throw new RangeError("interval must satisfy 0 <= startTime < endTime.");
}
if (windowDuration <= 0 || hopDuration <= 0) {
throw new RangeError("windowDuration and hopDuration must be positive.");
}
if (Number.isFinite(scene?.duration) && endTime > scene.duration) {
throw new RangeError("endTime must not exceed scene.duration.");
}
const windows = [];
for (let windowStart = startTime; windowStart < endTime; windowStart += hopDuration) {
const windowEnd = Math.min(windowStart + windowDuration, endTime);
windows.push(sampleQuasiStaticWindow(scene, windowStart, windowEnd));
}
return windows;
}
+41 -10
View File
@@ -232,6 +232,22 @@ function validateSceneRelationships(value) {
if (requireString(source.id, `${path}.id`, issues)) sourceIds.push(source.id);
requireString(source.name, `${path}.name`, issues);
requirePosition(source.position, `${path}.position`, issues);
if (Array.isArray(source.trajectory)) {
let previousTime = -Infinity;
source.trajectory.forEach((keyframe, keyframeIndex) => {
const keyframePath = `${path}.trajectory[${keyframeIndex}]`;
if (!requireRecord(keyframe, keyframePath, issues)) return;
const timeValid = requireFinite(keyframe.time, `${keyframePath}.time`, issues, {
min: 0,
max: sceneDurationValid ? value.duration : 300,
});
requirePosition(keyframe.position, `${keyframePath}.position`, issues);
if (timeValid && keyframe.time <= previousTime) {
addIssue(issues, `${keyframePath}.time`, "trajectory time은 앞 keyframe보다 커야 합니다.");
}
if (timeValid) previousTime = keyframe.time;
});
}
requireFinite(source.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 });
const startValid = requireFinite(source.startTime, `${path}.startTime`, issues, { min: 0 });
const durationValid = requireFinite(source.duration, `${path}.duration`, issues, {
@@ -331,21 +347,36 @@ function validateExperimentRelationships(value, context = {}) {
exclusiveMin: true,
});
}
if (value.evaluation !== undefined && requireRecord(value.evaluation, "$.evaluation", issues)) {
const threshold = value.evaluation.detectionThresholdRatio;
if (!isFiniteNumber(threshold) || threshold <= 0 || threshold > 1) {
addIssue(
issues,
"$.evaluation.detectionThresholdRatio",
"피크 검출 임계값 비율은 0보다 크고 1 이하여야 합니다. 더 약한 피크를 찾으려면 값을 낮추세요.",
);
}
}
if (context.arrayDefinition && context.scene) {
const retainedBufferCount = context.arrayDefinition.microphones.length + context.scene.sources.length + 1;
const sampleCount = Math.ceil(context.scene.duration * context.scene.sampleRate);
let maximumDelaySamples = 0;
for (const source of context.scene.sources) {
for (const microphone of context.arrayDefinition.microphones) {
const distance = Math.hypot(
source.position[0] - microphone.position[0],
source.position[1] - microphone.position[1],
source.position[2] - microphone.position[2],
);
maximumDelaySamples = Math.max(
maximumDelaySamples,
(distance / context.scene.speedOfSound) * context.scene.sampleRate,
);
const sourcePositions = Array.isArray(source.trajectory)
? source.trajectory.map((keyframe) => keyframe.position)
: [source.position];
for (const sourcePosition of sourcePositions) {
for (const microphone of context.arrayDefinition.microphones) {
const distance = Math.hypot(
sourcePosition[0] - microphone.position[0],
sourcePosition[1] - microphone.position[1],
sourcePosition[2] - microphone.position[2],
);
maximumDelaySamples = Math.max(
maximumDelaySamples,
(distance / context.scene.speedOfSound) * context.scene.sampleRate,
);
}
}
}
let delayFftSize = 1;
+1 -1
View File
@@ -264,7 +264,7 @@ p {
.quick-start-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 18px;
margin-top: 18px;
}
+3 -3
View File
@@ -5,8 +5,8 @@
JSON Schema Draft 2020-12 형식입니다.
- `array.schema.json`: 배열 크기, 좌표계, 마이크 위치와 선택적 보정값
- `scene.schema.json`: 재현 가능한 음원 recipe, asset 참조와 ground-truth 위치
- `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT와 DAS 설정
- `result.schema.json`: raw/display/후처리 map, 복수 peak, 실행 timing과 평가 지표
- `scene.schema.json`: 재현 가능한 음원 recipe, asset 참조, 정적 위치와 선택적 trajectory
- `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT/DAS와 peak 검출 임계값
- `result.schema.json`: raw/display/후처리 map, 복수 peak, 비확률 beam spread, timing과 평가 지표
중복 id, Nyquist, 시간 구간과 참조 무결성은 `packages/contracts/validation.js`에서 추가로 검증합니다.
+7
View File
@@ -47,6 +47,13 @@
"normalization": { "enum": ["peak", "none"] },
"dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 }
}
},
"evaluation": {
"type": "object", "additionalProperties": false,
"required": ["detectionThresholdRatio"],
"properties": {
"detectionThresholdRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }
}
}
}
}
+38
View File
@@ -66,10 +66,48 @@
"sourceId": { "type": "string", "minLength": 1 },
"position": { "$ref": "#/$defs/position" },
"value": { "type": "number", "minimum": 0 },
"beamSpread": { "$ref": "#/$defs/beamSpread" },
"localizationErrorM": { "type": "number", "minimum": 0 },
"localizationErrorPixels": { "type": "number", "minimum": 0 }
}
},
"beamSpread": {
"type": "object", "additionalProperties": false,
"required": [
"calibrated", "method", "halfPowerRatio", "thresholdValue", "pixelCount",
"centroid", "covarianceM2", "ellipse"
],
"properties": {
"calibrated": { "const": false },
"method": { "const": "connected-half-power-power-weighted-covariance" },
"halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
"thresholdValue": { "type": "number", "minimum": 0 },
"pixelCount": { "type": "integer", "minimum": 0, "maximum": 262144 },
"centroid": { "$ref": "#/$defs/position" },
"covarianceM2": {
"type": "object", "additionalProperties": false,
"required": ["xx", "xy", "yy"],
"properties": {
"xx": { "type": "number", "minimum": 0 },
"xy": { "type": "number" },
"yy": { "type": "number", "minimum": 0 }
}
},
"ellipse": {
"type": "object", "additionalProperties": false,
"required": [
"axisScale", "angleReference", "majorRmsRadiusM", "minorRmsRadiusM", "angleDeg"
],
"properties": {
"axisScale": { "const": "rms" },
"angleReference": { "const": "counterclockwise-from-positive-x" },
"majorRmsRadiusM": { "type": "number", "minimum": 0 },
"minorRmsRadiusM": { "type": "number", "minimum": 0 },
"angleDeg": { "type": "number", "minimum": -180, "maximum": 180 }
}
}
}
},
"evaluation": {
"type": "object", "additionalProperties": false,
"required": [
+11
View File
@@ -31,6 +31,13 @@
"type": "array", "prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
"items": false, "minItems": 3, "maxItems": 3
},
"trajectoryKeyframe": {
"type": "object", "additionalProperties": false, "required": ["time", "position"],
"properties": {
"time": { "type": "number", "minimum": 0 },
"position": { "$ref": "#/$defs/position" }
}
},
"image": {
"type": "object", "additionalProperties": false,
"required": ["assetId", "widthPixels", "heightPixels", "plane"],
@@ -80,6 +87,10 @@
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"name": { "type": "string", "minLength": 1, "maxLength": 120 }, "position": { "$ref": "#/$defs/position" },
"trajectory": {
"type": "array", "minItems": 2, "maxItems": 4096,
"items": { "$ref": "#/$defs/trajectoryKeyframe" }
},
"gainDb": { "type": "number", "minimum": -120, "maximum": 24 }, "startTime": { "type": "number", "minimum": 0 },
"duration": { "type": "number", "exclusiveMinimum": 0 }, "signal": { "$ref": "#/$defs/signal" }
}
+219
View File
@@ -0,0 +1,219 @@
[CmdletBinding()]
param(
[ValidateRange(1024, 65535)]
[int]$Port = 4193,
[ValidateRange(10, 900)]
[int]$TimeoutSeconds = 180,
[string]$BrowserPath = ""
)
$ErrorActionPreference = 'Stop'
$projectRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$serveScript = Join-Path $PSScriptRoot 'serve.ps1'
$serverProcess = $null
$browserProcess = $null
$socket = $null
$exitCode = 1
$profilePath = Join-Path ([System.IO.Path]::GetTempPath()) ("micarrsim-tests-" + [guid]::NewGuid().ToString('N'))
function Resolve-BrowserPath {
param([string]$RequestedPath)
if (-not [string]::IsNullOrWhiteSpace($RequestedPath)) {
$resolved = [System.IO.Path]::GetFullPath($RequestedPath)
if (-not [System.IO.File]::Exists($resolved)) {
throw "Browser executable not found: $resolved"
}
return $resolved
}
$candidates = @(
'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
'C:\Program Files\Microsoft\Edge\Application\msedge.exe',
'C:\Program Files\Google\Chrome\Application\chrome.exe',
'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe'
)
foreach ($candidate in $candidates) {
if ([System.IO.File]::Exists($candidate)) { return $candidate }
}
throw 'Microsoft Edge or Google Chrome is required to run browser tests.'
}
function Get-FreeLoopbackPort {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
try {
$listener.Start()
return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
}
finally {
$listener.Stop()
}
}
function Wait-ForHttp {
param([string]$Url, [int]$Seconds)
$deadline = [DateTime]::UtcNow.AddSeconds($Seconds)
do {
try {
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2
if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) { return }
}
catch {
Start-Sleep -Milliseconds 100
}
} while ([DateTime]::UtcNow -lt $deadline)
throw "Timed out waiting for $Url"
}
function Send-CdpMessage {
param([System.Net.WebSockets.ClientWebSocket]$Client, [hashtable]$Message)
$json = $Message | ConvertTo-Json -Compress -Depth 10
[byte[]]$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
$segment = [System.ArraySegment[byte]]::new($bytes)
$Client.SendAsync(
$segment,
[System.Net.WebSockets.WebSocketMessageType]::Text,
$true,
[System.Threading.CancellationToken]::None
).GetAwaiter().GetResult() | Out-Null
}
function Receive-CdpMessage {
param([System.Net.WebSockets.ClientWebSocket]$Client)
$buffer = New-Object byte[] 65536
$stream = [System.IO.MemoryStream]::new()
try {
do {
$segment = [System.ArraySegment[byte]]::new($buffer)
$received = $Client.ReceiveAsync(
$segment,
[System.Threading.CancellationToken]::None
).GetAwaiter().GetResult()
if ($received.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) {
throw 'Browser DevTools connection closed before tests completed.'
}
$stream.Write($buffer, 0, $received.Count)
} while (-not $received.EndOfMessage)
return [System.Text.Encoding]::UTF8.GetString($stream.ToArray()) | ConvertFrom-Json
}
finally {
$stream.Dispose()
}
}
try {
$browser = Resolve-BrowserPath $BrowserPath
$debugPort = Get-FreeLoopbackPort
$serverUrl = "http://127.0.0.1:$Port/"
$testUrl = "${serverUrl}tests/"
$serverArguments = @(
'-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass',
'-File', ('"' + $serveScript + '"'), '-Port', [string]$Port, '-NoBrowser'
)
$serverProcess = Start-Process -FilePath 'powershell.exe' -ArgumentList $serverArguments -WindowStyle Hidden -PassThru
Wait-ForHttp -Url $testUrl -Seconds 15
[System.IO.Directory]::CreateDirectory($profilePath) | Out-Null
$browserArguments = @(
'--headless=new', '--disable-gpu', '--no-first-run', '--disable-background-networking',
"--remote-debugging-port=$debugPort", ('--user-data-dir="' + $profilePath + '"'), $testUrl
)
$browserProcess = Start-Process -FilePath $browser -ArgumentList $browserArguments -WindowStyle Hidden -PassThru
Wait-ForHttp -Url "http://127.0.0.1:$debugPort/json/version" -Seconds 15
$page = $null
$pageDeadline = [DateTime]::UtcNow.AddSeconds(15)
do {
$pages = Invoke-RestMethod -Uri "http://127.0.0.1:$debugPort/json/list" -TimeoutSec 2
$page = $pages | Where-Object { $_.type -eq 'page' -and $_.url -like "${testUrl}*" } | Select-Object -First 1
if (-not $page) { Start-Sleep -Milliseconds 100 }
} while (-not $page -and [DateTime]::UtcNow -lt $pageDeadline)
if (-not $page) { throw 'Could not find the browser test page.' }
$socket = [System.Net.WebSockets.ClientWebSocket]::new()
$socket.ConnectAsync(
[uri]$page.webSocketDebuggerUrl,
[System.Threading.CancellationToken]::None
).GetAwaiter().GetResult() | Out-Null
$timeoutMilliseconds = $TimeoutSeconds * 1000
$expression = @"
new Promise((resolve) => {
const deadline = Date.now() + $timeoutMilliseconds;
const poll = () => {
const status = document.documentElement.dataset.testStatus;
if (status || Date.now() >= deadline) {
resolve({
status: status || 'timeout',
summary: document.querySelector('#test-summary')?.textContent || 'No test summary',
failures: [...document.querySelectorAll('.test-result--fail')].map((item) => item.innerText),
});
return;
}
setTimeout(poll, 100);
};
poll();
})
"@
Send-CdpMessage -Client $socket -Message @{
id = 1
method = 'Runtime.evaluate'
params = @{ expression = $expression; awaitPromise = $true; returnByValue = $true }
}
$response = $null
do {
$candidate = Receive-CdpMessage -Client $socket
if ($candidate.id -eq 1) { $response = $candidate }
} while (-not $response)
if ($response.error) { throw ($response.error | ConvertTo-Json -Compress -Depth 8) }
if ($response.result.exceptionDetails) {
throw ($response.result.exceptionDetails | ConvertTo-Json -Compress -Depth 8)
}
$testResult = $response.result.result.value
Write-Host $testResult.summary
foreach ($failure in @($testResult.failures)) { Write-Host $failure -ForegroundColor Red }
if ($testResult.status -eq 'pass') {
Write-Host 'Browser tests passed.' -ForegroundColor Green
$exitCode = 0
}
elseif ($testResult.status -eq 'timeout') {
Write-Host "Browser tests did not finish within $TimeoutSeconds seconds." -ForegroundColor Red
}
else {
Write-Host 'Browser tests failed.' -ForegroundColor Red
}
}
catch {
Write-Error $_
}
finally {
if ($socket) {
try {
if ($socket.State -eq [System.Net.WebSockets.WebSocketState]::Open) {
$socket.CloseAsync(
[System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure,
'test complete',
[System.Threading.CancellationToken]::None
).GetAwaiter().GetResult() | Out-Null
}
}
catch { }
$socket.Dispose()
}
foreach ($process in @($browserProcess, $serverProcess)) {
if ($process -and -not $process.HasExited) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
}
}
Start-Sleep -Milliseconds 200
$temporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
$resolvedProfile = [System.IO.Path]::GetFullPath($profilePath)
if (
$resolvedProfile.StartsWith($temporaryRoot, [System.StringComparison]::OrdinalIgnoreCase) -and
[System.IO.Path]::GetFileName($resolvedProfile).StartsWith('micarrsim-tests-', [System.StringComparison]::Ordinal)
) {
Remove-Item -LiteralPath $resolvedProfile -Recurse -Force -ErrorAction SilentlyContinue
}
}
exit $exitCode
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\run-tests.ps1" %*
exit /b %ERRORLEVEL%
+9 -6
View File
@@ -4,9 +4,11 @@
## 실행
1. 저장소의 `start.cmd`를 실행합니다.
2. 최신 Edge 또는 Chrome에서 <http://127.0.0.1:4173/tests/>을 엽니다.
3. 모든 항목이 끝날 때까지 페이지를 열어 둡니다.
가장 간단한 자동 실행은 저장소 루트의 `test.cmd`입니다. 임시 local server와 headless Edge/Chrome을
실행하고 전체 browser suite가 끝나면 결과에 맞는 process exit code를 반환합니다.
수동 확인은 `start.cmd`를 실행한 뒤 최신 Edge 또는 Chrome에서
<http://127.0.0.1:4173/tests/>을 열고 모든 항목이 끝날 때까지 페이지를 유지합니다.
Unit과 integration test가 같은 페이지에서 실행됩니다. WebGPU 장치 초기화와 192채널 baseline은
수 초 이상 걸릴 수 있습니다. 페이지 제목과 요약이 `PASS`인지 확인하고 실패 항목의 stack을
@@ -18,9 +20,10 @@ Unit과 integration test가 같은 페이지에서 실행됩니다. WebGPU 장
- `webgpu.test.js`: 실제 adapter가 있으면 CPU/GPU map 오차, peak와 강제 bin/point batching;
adapter가 없으면 Auto CPU fallback
- `server.test.js`: 숨김 저장소 경로 차단, GET/HEAD 제한과 보안 response headers
- `builder-ui.test.js`: 이미지·오디오 연결, canvas 편집, Preview WAV/Scene 다운로드, SHA 재연결과
잘못된 import 보존
- `camera-ui.test.js`: Dedicated Worker CPU/WebGPU 실행, 취소, Result schema, rectangular/Fermat 비교
- `builder-ui.test.js`: 파일 선택 한 번으로 오디오 음원 생성, 이미지·오디오 연결, canvas 편집,
trajectory keyframe 편집, Preview WAV/Scene 다운로드, SHA 재연결과 잘못된 import 보존
- `camera-ui.test.js`: Dedicated Worker CPU/WebGPU 실행, 취소, Result schema, rectangular/Fermat 비교;
이동 예제 로드, 8×8 CPU·2 Hz live map 준비, 복수 frame, scrub, 재생 시간 증가와 일시정지
- `workflow-ui.test.js`: Dataset Builder의 Scene JSON과 원본 assets를 Acoustic Camera에 넘겨
Result JSON을 저장하는 end-to-end 흐름
+19 -5
View File
@@ -73,20 +73,21 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev
const staleSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240"><rect width="320" height="240" fill="#ff0000"/></svg>';
const staleImageFile = new windowObject.File([staleSvg], "stale.svg", { type: "image/svg+xml" });
dispatchFiles(windowObject, documentObject.querySelector("#audio-input"), [audioFile]);
dispatchFiles(windowObject, documentObject.querySelector("#audio-source-input"), [audioFile]);
await waitFor(
() => [...documentObject.querySelectorAll(".asset-chip__state")].some((item) => item.textContent === "연결됨"),
"오디오 hash/decode",
"오디오 파일 음원 생성",
);
await waitFor(() => documentObject.querySelectorAll(".source-card").length === 2, "asset source card");
assertEqual(documentObject.querySelector("#source-type").value, "asset", "import selects asset source");
assertEqual(documentObject.querySelector("#source-name").value, "machine", "source name comes from file");
assert(documentObject.querySelector("#source-asset").value !== "", "imported asset is selected");
dispatchFiles(windowObject, documentObject.querySelector("#image-input"), [staleImageFile]);
dispatchFiles(windowObject, documentObject.querySelector("#image-input"), [imageFile]);
await waitFor(() => documentObject.querySelectorAll(".asset-chip__state").length === 2, "이미지 decode");
await waitFor(() => documentObject.documentElement.dataset.assetsReady === "true", "asset 준비");
documentObject.querySelector("#add-source-button").click();
assertEqual(documentObject.querySelectorAll(".source-card").length, 2, "source card count");
dispatchValue(windowObject, documentObject.querySelector("#source-name"), "asset source");
dispatchValue(windowObject, documentObject.querySelector("#source-type"), "asset", "change");
dispatchChecked(windowObject, documentObject.querySelector("#source-loop"), true);
const canvas = documentObject.querySelector("#scene-canvas");
@@ -99,6 +100,16 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev
assertNear(Number(documentObject.querySelector("#source-x").value), 0.3, 0.002, "canvas x");
assertNear(Number(documentObject.querySelector("#source-y").value), 0.225, 0.002, "canvas y");
dispatchChecked(windowObject, documentObject.querySelector("#source-motion"), true);
assertEqual(documentObject.querySelectorAll(".trajectory-row").length, 2, "default trajectory points");
assertEqual(documentObject.querySelector(".trajectory-row input").value, "0", "trajectory starts at zero");
documentObject.querySelector("#add-keyframe-button").click();
assertEqual(documentObject.querySelectorAll(".trajectory-row").length, 3, "keyframe added");
documentObject.querySelectorAll(".trajectory-row")[1].querySelector(".trajectory-row__remove").click();
assertEqual(documentObject.querySelectorAll(".trajectory-row").length, 2, "keyframe removed");
dispatchValue(windowObject, documentObject.querySelector("#preview-time"), "0.1");
assertEqual(documentObject.querySelector("#preview-time-value").textContent, "0.10 s", "preview time label");
dispatchValue(windowObject, documentObject.querySelector("#ambient-type"), "asset", "change");
dispatchChecked(windowObject, documentObject.querySelector("#ambient-loop"), true);
await waitFor(() => documentObject.documentElement.dataset.sceneValid === "true", "편집 scene 검증");
@@ -121,6 +132,9 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev
const firstScene = JSON.parse(firstJsonText);
assertEqual(firstScene.sources.length, 2, "exported source count");
assertEqual(firstScene.sources[1].signal.loop, true, "source loop exported");
assertEqual(firstScene.sources[1].trajectory.length, 2, "trajectory exported");
assertEqual(firstScene.sources[1].trajectory[0].position.join(","), firstScene.sources[1].position.join(","), "fallback position synced");
assertEqual(firstScene.sources[1].trajectory[1].time, firstScene.duration, "trajectory spans scene");
assertEqual(firstScene.ambient[0].signal.loop, true, "ambient loop exported");
assertEqual(firstScene.image.widthPixels, 640, "image width exported");
assert(firstScene.assets.every((asset) => /^[a-f0-9]{64}$/.test(asset.sha256)), "asset hashes exported");
+98
View File
@@ -68,6 +68,8 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
assertEqual(documentObject.querySelector("#detection-threshold").value, "25", "default peak sensitivity");
dispatchValue(windowObject, documentObject.querySelector("#detection-threshold"), 10);
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true" &&
!documentObject.querySelector("#run-button").disabled,
@@ -134,6 +136,11 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
assertEqual(resultDocument.bands[0].rawMap.values.length, 64, "exported map value count");
assert(/^[a-f0-9]{64}$/.test(resultDocument.inputHash), "exported input hash");
assertEqual(resultDocument.bands[0].evaluation.version, "1.0.0", "evaluation version");
assertEqual(
resultDocument.bands[0].evaluation.detectionThresholdRatio,
0.1,
"selected peak sensitivity is exported",
);
assert(Number.isFinite(resultDocument.bands[0].evaluation.matchToleranceM), "match tolerance exported");
assert(Number.isFinite(resultDocument.bands[0].metrics.mainLobeWidthM), "main-lobe metric exported");
assertEqual(resultDocument.environment.scanXMin, experiment.scan.xMin, "scan bounds exported");
@@ -209,3 +216,94 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
frame.remove();
}
});
test("Acoustic Camera prepares, scrubs, plays, and pauses the moving-source example", async () => {
const frame = document.createElement("iframe");
frame.src = "/apps/acoustic-camera/?integration-test=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 live iframe load failed")), {
once: true,
});
});
const windowObject = frame.contentWindow;
const documentObject = frame.contentDocument;
try {
await waitFor(
() => documentObject.documentElement.dataset.appReady === "true",
"Acoustic Camera live initialization",
);
documentObject.querySelector("#load-moving-example-button").click();
await waitFor(
() => documentObject.documentElement.dataset.inputBusy === "false" &&
documentObject.documentElement.dataset.cameraValid === "true" &&
documentObject.documentElement.dataset.liveState === "idle" &&
documentObject.querySelector("#experiment-id").value === "moving-tone-live" &&
!documentObject.querySelector("#prepare-live-button").disabled,
"valid moving example trajectory",
);
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
dispatchValue(windowObject, documentObject.querySelector("#live-map-rate"), 2);
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true" &&
documentObject.documentElement.dataset.liveState === "idle" &&
documentObject.querySelector("#scan-columns").value === "8" &&
documentObject.querySelector("#scan-rows").value === "8" &&
documentObject.querySelector("#backend").value === "cpu" &&
documentObject.querySelector("#live-map-rate").value === "2" &&
!documentObject.querySelector("#prepare-live-button").disabled,
"8x8 CPU 2 Hz live configuration",
);
documentObject.querySelector("#prepare-live-button").click();
await waitFor(
() => documentObject.documentElement.dataset.liveState === "ready",
"moving-source live preparation",
45000,
);
const frameCount = Number(documentObject.documentElement.dataset.liveFrameCount);
assert(frameCount > 1, `multiple live frames: ${frameCount}`);
assert(documentObject.querySelector("#live-status").textContent.includes("CPU"), "live backend is CPU");
dispatchValue(windowObject, documentObject.querySelector("#live-time"), 0.6, "input");
await waitFor(
() => Number(documentObject.documentElement.dataset.liveTime) >= 0.599,
"live scrub to a later time",
);
const scrubbedTime = Number(documentObject.documentElement.dataset.liveTime);
assert(Number(documentObject.documentElement.dataset.liveFrame) > 0, "scrub selects a later map frame");
documentObject.querySelector("#live-play-button").click();
await waitFor(
() => documentObject.documentElement.dataset.liveState === "playing",
"live playback start",
);
await waitFor(
() => Number(documentObject.documentElement.dataset.liveTime) > scrubbedTime + 0.05,
"live playback time advance",
5000,
);
documentObject.querySelector("#live-play-button").click();
await waitFor(
() => documentObject.documentElement.dataset.liveState === "ready",
"live playback pause",
);
const pausedTime = Number(documentObject.documentElement.dataset.liveTime);
await new Promise((resolve) => setTimeout(resolve, 120));
const timeAfterPause = Number(documentObject.documentElement.dataset.liveTime);
assert(
Math.abs(timeAfterPause - pausedTime) <= 0.01,
`paused live time remains stable: ${pausedTime} -> ${timeAfterPause}`,
);
} finally {
frame.remove();
}
});
+12 -8
View File
@@ -5,9 +5,11 @@
## 실행
1. 저장소의 `start.cmd`를 실행합니다.
2. 최신 Edge 또는 Chrome에서 <http://127.0.0.1:4173/tests/>을 엽니다.
3. 페이지 상단의 통과/실패 합계와 각 테스트 결과를 확인합니다.
가장 간단한 자동 실행은 저장소 루트의 `test.cmd`입니다. 임시 local server와 headless Edge/Chrome을
실행하고 결과에 맞는 process exit code를 반환합니다.
수동 확인은 `start.cmd`를 실행한 뒤 최신 Edge 또는 Chrome에서
<http://127.0.0.1:4173/tests/>을 열어 페이지 상단의 통과/실패 합계와 각 결과를 확인합니다.
Unit과 integration module은 같은 browser runner에서 순서대로 실행됩니다. 다시 실행하려면 페이지를
새로 고치세요. 다른 포트를 사용했다면 URL의 포트도 바꾸며, `tests/index.html`을 파일로 직접 열면
@@ -15,11 +17,13 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩
## 범위
- `contracts.test.js`: Array·Scene·Experiment·Result schema, 관계 검증, 안전한 asset 경로와 fixture
- `acoustics.test.js`: seed 재현성, mix/정규화, 자유음장 전파, calibration, STFT와 CPU DAS
- `builder.test.js`: Builder scene model, source/asset 조작, 오디오 크기와 WAV 사전 검증
- `evaluation.test.js`: local peak, truth matching, 위치 오차, energy, sidelobe와 main-lobe 지표
- `camera.test.js`: canonical input hash, workload 추정, 거리 이동, Result 생성과 schema 검증
- `contracts.test.js`: Array·Scene·Experiment·Result schema, 관계 검증, 안전한 asset 경로와 이동 fixture
- `acoustics.test.js`: seed 재현성, 자유음장 전파, trajectory 보간, sample 정렬 시간창과
tone/noise/asset 절대 시간 연속성, calibration, STFT와 CPU DAS
- `builder.test.js`: Builder scene model, source/asset·trajectory 조작, 오디오 크기와 WAV 사전 검증
- `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적
beam-spread covariance ellipse
- `camera.test.js`: canonical input hash, workload 추정, 이동 timeline 계획·재생 보간, Result schema 검증
- `webgpu.test.js`: WGSL buffer packing, bin/point batching, 장치 한계와 typed CPU fallback 정책
WebGPU unit test 대부분은 실제 GPU 없이 planner와 binary layout을 검증합니다. 실제 GPU 수치 일치는
+169
View File
@@ -9,7 +9,14 @@ import { dbToLinear, distance3 } from "/packages/acoustics-core/math.js";
import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
import { renderSignal } from "/packages/acoustics-core/signal.js";
import { getFrameCount } from "/packages/acoustics-core/stft.js";
import {
createQuasiStaticWindows,
sampleQuasiStaticWindow,
sceneAtTime,
sourcePositionAtTime,
} from "/packages/acoustics-core/trajectory.js";
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
function assertNear(actual, expected, tolerance, label) {
@@ -39,6 +46,73 @@ function createArray(columns, rows, width, height) {
return { id: "test-array", aperture: { width, height }, microphones };
}
test("trajectory 위치를 선형 보간하고 시간창 중앙에서 준정적으로 샘플링한다", () => {
const source = {
position: [9, 8, 7],
trajectory: [
{ time: 0.2, position: [-1, 0, 2] },
{ time: 0.6, position: [1, 2, 4] },
{ time: 1, position: [3, 0, 2] },
],
};
assertEqual(sourcePositionAtTime({ position: [4, 5, 6] }, 0.5).join(","), "4,5,6", "fixed fallback");
assertEqual(sourcePositionAtTime(source, 0).join(","), "-1,0,2", "before first keyframe");
assertEqual(sourcePositionAtTime(source, 1.5).join(","), "3,0,2", "after last keyframe");
const interpolated = sourcePositionAtTime(source, 0.4);
assertNear(interpolated[0], 0, 1e-12, "interpolated x");
assertNear(interpolated[1], 1, 1e-12, "interpolated y");
assertNear(interpolated[2], 3, 1e-12, "interpolated z");
const scene = { duration: 1, sources: [source] };
const sampled = sceneAtTime(scene, 0.4);
assert(sampled !== scene && sampled.sources[0] !== source, "sceneAtTime is immutable");
assertEqual(source.position.join(","), "9,8,7", "original fallback position is unchanged");
assertEqual(sampled.sources[0].trajectory, source.trajectory, "trajectory is preserved");
assertNear(sampled.sources[0].position[0], 0, 1e-12, "sampled scene x");
assertNear(sampled.sources[0].position[1], 1, 1e-12, "sampled scene y");
assertNear(sampled.sources[0].position[2], 3, 1e-12, "sampled scene z");
const windows = createQuasiStaticWindows(scene, { windowDuration: 0.4, hopDuration: 0.4 });
assertEqual(windows.length, 3, "partial final window");
assertNear(windows[0].sampleTime, 0.2, 1e-12, "first midpoint");
assertNear(windows[1].sampleTime, 0.6, 1e-12, "second midpoint");
assertNear(windows[2].sampleTime, 0.9, 1e-12, "partial midpoint");
assertNear(windows[2].duration, 0.2, 1e-12, "partial duration");
assertNear(windows[1].scene.sources[0].position[0], 1, 1e-12, "window x");
assertNear(windows[1].scene.sources[0].position[1], 2, 1e-12, "window y");
assertNear(windows[1].scene.sources[0].position[2], 4, 1e-12, "window z");
});
test("trajectory가 없는 sceneAtTime snapshot은 기존 합성 결과와 sample 단위로 동일하다", () => {
const scene = {
sampleRate: 8000,
duration: 0.01,
speedOfSound: 343,
seed: 17,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "fixed", name: "fixed", position: [0.1, -0.2, 1.2], gainDb: -12, startTime: 0, duration: 0.01,
signal: { type: "tone", frequency: 750, phaseDeg: 35 },
}],
ambient: [],
};
const arrayDefinition = {
microphones: [
{ id: "left", position: [-0.1, 0, 0] },
{ id: "right", position: [0.1, 0, 0], sensitivityDb: -1, phaseOffsetDeg: 3 },
],
};
const baseline = synthesizeMicrophoneSignals({ scene, arrayDefinition });
const sampled = synthesizeMicrophoneSignals({ scene: sceneAtTime(scene, 0.007), arrayDefinition });
assertEqual(sampled.appliedScale, baseline.appliedScale, "applied scale");
assertEqual(sampled.preNormalizationPeak, baseline.preNormalizationPeak, "pre-normalization peak");
baseline.channels.forEach((channel, channelIndex) => {
channel.forEach((value, sampleIndex) => {
assertEqual(sampled.channels[channelIndex][sampleIndex], value, `channel ${channelIndex} sample ${sampleIndex}`);
});
});
});
test("거리, dB와 구면 감쇠 기본식을 계산한다", () => {
assertNear(distance3([0, 0, 0], [1, 2, 2]), 3, 1e-12, "distance");
assertNear(dbToLinear(-6.020599913), 0.5, 1e-9, "dB amplitude");
@@ -369,3 +443,98 @@ test("PCM16 WAV가 표준 mono header를 쓰고 범위 밖 sample을 clamp한다
assertEqual(actualSamples[index], expected, `PCM sample ${index}`);
});
});
test("quasi-static synthesis window aligns samples, clips activity, and samples midpoint positions", () => {
const scene = {
sampleRate: 10,
duration: 1,
sources: [
{ id: "ended", position: [0, 0, 1], startTime: 0, duration: 0.2, signal: { type: "tone", frequency: 1 } },
{
id: "active", position: [0, 0, 1], startTime: 0.15, duration: 0.5,
trajectory: [
{ time: 0, position: [0, 0, 1] },
{ time: 1, position: [1, 2, 3] },
],
signal: { type: "tone", frequency: 1 },
},
{ id: "starts-inside", position: [0, 0, 1], startTime: 0.45, duration: 0.2, signal: { type: "tone", frequency: 1 } },
{ id: "later", position: [0, 0, 1], startTime: 0.8, duration: 0.1, signal: { type: "tone", frequency: 1 } },
],
ambient: [{ id: "room", signal: { type: "noise", color: "white" } }],
};
const window = sampleQuasiStaticWindow(scene, 0.21, 0.69);
assertEqual(window.startSample, 3, "aligned start sample");
assertEqual(window.endSample, 7, "aligned end sample");
assertEqual(window.sampleCount, 4, "window sample count");
assertNear(window.scene.duration, 0.4, 1e-12, "short scene duration");
assertEqual(window.scene.sources.map(({ id }) => id).join(","), "active,starts-inside", "active sources");
assertNear(window.scene.sources[0].position[0], 0.5, 1e-12, "midpoint x");
assertNear(window.scene.sources[0].position[1], 1, 1e-12, "midpoint y");
assertNear(window.scene.sources[0].startTime, 0, 1e-12, "active local start");
assertNear(window.scene.sources[0].duration, 0.4, 1e-12, "active clipped duration");
assertNear(window.scene.sources[1].startTime, 0.2, 1e-12, "inside local start");
assertNear(window.scene.sources[1].duration, 0.2, 1e-12, "inside clipped duration");
assertEqual(window.signalOffsets.sceneSampleOffset, 3, "absolute output origin");
assertNear(window.signalOffsets.sources[0].signalStartTime, 0.15, 1e-12, "signal time origin");
assertEqual(window.signalOffsets.sources[0].signalStartSample, 2, "signal sample origin");
assertEqual(window.signalOffsets.ambient.length, 1, "ambient offset count");
assertEqual(scene.sources.length, 4, "input scene is unchanged");
});
test("absolute scene windows preserve tone, noise, asset, ambient, and synthesis continuity", () => {
const scene = {
sampleRate: 16,
duration: 1,
speedOfSound: 343,
seed: 23,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [
{ id: "tone", position: [0, 0, 0], gainDb: -3, startTime: 1 / 16, duration: 14 / 16, signal: { type: "tone", frequency: 3, phaseDeg: 27 } },
{ id: "noise", position: [0, 0, 0], gainDb: -6, startTime: 2 / 16, duration: 13 / 16, signal: { type: "noise", color: "pink", seedOffset: 9 } },
{ id: "asset-source", position: [0, 0, 0], gainDb: 0, startTime: 3 / 16, duration: 12 / 16, signal: { type: "asset", assetId: "clip", channel: 0, loop: true } },
],
ambient: [{ id: "ambient", gainDb: -9, signal: { type: "noise", color: "white", seedOffset: 4 } }],
};
const assetBuffers = new Map([["clip", {
sampleRate: 16,
channels: [Float32Array.from({ length: 7 }, (_, index) => (index - 3) / 4)],
}]]);
const window = sampleQuasiStaticWindow(scene, 4 / 16, 12 / 16);
scene.sources.forEach((source, index) => {
const whole = renderSignal(source, scene, assetBuffers);
const partial = renderSignal(window.scene.sources[index], window.scene, assetBuffers, 0, {
sceneSampleOffset: window.signalOffsets.sceneSampleOffset,
...window.signalOffsets.sources[index],
});
partial.forEach((value, sampleIndex) => {
assertEqual(value, whole[window.startSample + sampleIndex], `${source.id} sample ${sampleIndex}`);
});
});
const wholeAmbient = renderSignal(scene.ambient[0], scene, assetBuffers, 77);
const partialAmbient = renderSignal(window.scene.ambient[0], window.scene, assetBuffers, 77, {
sceneSampleOffset: window.signalOffsets.sceneSampleOffset,
...window.signalOffsets.ambient[0],
});
partialAmbient.forEach((value, sampleIndex) => {
assertEqual(value, wholeAmbient[window.startSample + sampleIndex], `ambient sample ${sampleIndex}`);
});
const arrayDefinition = { microphones: [{ id: "mic", position: [0, 0, 0] }] };
const wholeSynthesis = synthesizeMicrophoneSignals({ scene, arrayDefinition, assetBuffers, referenceDistance: 1, minimumDistance: 1 });
const partialSynthesis = synthesizeMicrophoneSignals({
scene: window.scene,
arrayDefinition,
assetBuffers,
signalOffsets: window.signalOffsets,
referenceDistance: 1,
minimumDistance: 1,
});
assertEqual(partialSynthesis.channels[0].length, window.sampleCount, "short synthesis length");
partialSynthesis.channels[0].forEach((value, sampleIndex) => {
assertNear(value, wholeSynthesis.channels[0][window.startSample + sampleIndex], 2e-5, `mic sample ${sampleIndex}`);
});
});
+31
View File
@@ -1,11 +1,17 @@
import { coordinateToPixel, pixelToCoordinate } from "/apps/dataset-builder/canvas-view.js";
import {
assetDescriptorForFile,
disableSourceTrajectory,
enableSourceTrajectory,
createSceneId,
createSource,
insertTrajectoryKeyframe,
matchAssetDescriptor,
prepareSceneForEditing,
removeTrajectoryKeyframe,
referencedAssetIds,
scaleSceneTrajectories,
updateTrajectoryKeyframeTime,
} from "/apps/dataset-builder/model.js";
import { decodeAudioFile } from "/packages/audio-io/browser-audio.js";
import {
@@ -43,6 +49,31 @@ test("Builder model이 schema-safe하고 안정적인 source/asset id를 만든
assert(/^[a-z0-9][a-z0-9._-]*$/.test(createSceneId()), "generated scene id is schema-safe");
});
test("Builder가 이동 경로를 순서대로 편집하고 fallback position을 첫 점에 맞춘다", () => {
const source = { position: [0.5, 0.1, 2] };
const trajectory = enableSourceTrajectory(source, 2, { xMin: -0.6, xMax: 0.6, yMin: -0.45, yMax: 0.45 });
assertEqual(trajectory.length, 2, "default keyframe count");
assertEqual(trajectory[0].time, 0, "path starts at scene start");
assertEqual(trajectory[1].time, 2, "path spans scene duration");
assert(trajectory[1].position[0] < trajectory[0].position[0], "edge path turns inward");
const inserted = insertTrajectoryKeyframe(source);
assertEqual(inserted, 1, "midpoint insertion index");
assertNear(source.trajectory[1].time, 1, 1e-12, "midpoint time");
assertNear(source.trajectory[1].position[0], 0.35, 1e-12, "midpoint x");
assertNear(updateTrajectoryKeyframeTime(source, 1, 3, 2), 1.999999998, 1e-12, "ordered time clamp");
source.trajectory[0].position = [-0.25, 0.2, 1.5];
scaleSceneTrajectories({ sources: [source] }, 2, 4);
assertNear(source.trajectory[1].time, 3.999999996, 1e-9, "duration scales keyframes");
assertEqual(source.position.join(","), "-0.25,0.2,1.5", "first point fallback sync");
assert(removeTrajectoryKeyframe(source, 1), "remove optional midpoint");
assertEqual(removeTrajectoryKeyframe(source, 0), false, "two-point minimum");
disableSourceTrajectory(source);
assertEqual(source.trajectory, undefined, "motion disabled");
assertEqual(source.position.join(","), "-0.25,0.2,1.5", "fallback remains after disable");
});
test("asset 재연결은 fileName보다 SHA-256을 우선하고 충돌을 분리한다", () => {
const assets = [
{ id: "old", kind: "audio", fileName: "audio/machine.wav", sha256: "a".repeat(64) },
+211 -1
View File
@@ -1,3 +1,10 @@
import {
createAnalysisFramePlan,
playbackFrameAtTime,
sceneHasTrajectories,
scenePlaybackState,
} from "/apps/acoustic-camera/timeline.js";
import {
alignExperimentToScene,
canonicalStringify,
@@ -15,6 +22,154 @@ function assertNear(actual, expected, tolerance, label) {
);
}
function assertThrows(callback, expectedText, label) {
let error;
try {
callback();
} catch (caught) {
error = caught;
}
assert(error instanceof Error, `${label}: expected an error`);
assert(error.message.includes(expectedText), `${label}: unexpected message ${error.message}`);
}
test("Camera detects moving source trajectories", () => {
assertEqual(sceneHasTrajectories(null), false, "missing scene");
assertEqual(sceneHasTrajectories({ sources: [{ id: "fixed", position: [0, 0, 2] }] }), false, "fixed scene");
assertEqual(sceneHasTrajectories({
sources: [{
id: "moving",
position: [0, 0, 2],
trajectory: [
{ time: 0, position: [0, 0, 2] },
{ time: 1, position: [1, 0, 2] },
],
}],
}), true, "moving scene");
});
test("Camera analysis plan covers a scene and respects its frame budget", () => {
const scene = { duration: 2, sampleRate: 1000, sources: [] };
const options = { fftSize: 100, targetMapHz: 4, maxFrames: 3 };
const first = createAnalysisFramePlan(scene, options);
const second = createAnalysisFramePlan(scene, options);
assertEqual(first.frameCount, 3, "frame count is capped");
assertNear(first.minimumWindowDuration, 0.1, 1e-12, "minimum FFT window");
assertNear(first.windowDuration, 2 / 3, 1e-12, "windows grow to cover capped timeline");
assertEqual(first.frames[0].startTime, 0, "first frame begins at zero");
assertEqual(first.frames.at(-1).endTime, 2, "last frame reaches scene end");
first.frames.forEach((frame, index) => {
assertEqual(frame.index, index, `frame ${index} index`);
assertNear(frame.sampleTime, (frame.startTime + frame.endTime) / 2, 1e-12, `frame ${index} midpoint`);
if (index > 0) {
assert(frame.startTime <= first.frames[index - 1].endTime, `frame ${index} leaves no timeline gap`);
}
});
assertEqual(JSON.stringify(second), JSON.stringify(first), "frame planning is deterministic");
});
test("Camera analysis windows obey FFT and short-scene constraints", () => {
const fftLimited = createAnalysisFramePlan(
{ duration: 1, sampleRate: 1000, sources: [] },
{ fftSize: 400, targetMapHz: 10, maxFrames: 100 },
);
assertEqual(fftLimited.frameCount, 10, "requested timeline frames");
assertNear(fftLimited.windowDuration, 0.4, 1e-12, "FFT-sized window");
assertEqual(fftLimited.frames[0].startTime, 0, "overlapping first window");
assertEqual(fftLimited.frames.at(-1).endTime, 1, "overlapping last window");
assert(fftLimited.frames.every((frame) => frame.duration >= 0.4 - 1e-12), "no window is shorter than one FFT");
const shortScene = createAnalysisFramePlan(
{ duration: 0.05, sampleRate: 1000, sources: [] },
{ fftSize: 100, targetMapHz: 100, maxFrames: 50 },
);
assertEqual(shortScene.frameCount, 1, "short scene avoids duplicate frames");
assertEqual(shortScene.windowDuration, 0.05, "window is bounded by scene duration");
assertEqual(shortScene.zeroPaddingRequired, true, "short scene reports FFT padding requirement");
assertNear(shortScene.frames[0].sampleTime, 0.025, 1e-12, "short scene midpoint");
});
test("Camera playback selects and interpolates bounded analysis frames", () => {
const plan = createAnalysisFramePlan(
{ duration: 1, sampleRate: 1000, sources: [] },
{ fftSize: 100, targetMapHz: 4, maxFrames: 10 },
);
const middle = playbackFrameAtTime(plan, 0.5);
assertEqual(middle.leftFrameIndex, 1, "middle left frame");
assertEqual(middle.rightFrameIndex, 2, "middle right frame");
assertEqual(middle.frameIndex, 2, "nearest frame resolves midpoint ties forward");
assertNear(middle.progress, 0.5, 1e-12, "middle interpolation progress");
assertNear(middle.framePosition, 1.5, 1e-12, "fractional frame position");
const before = playbackFrameAtTime(plan, -10);
assertEqual(before.time, 0, "negative playback time is clamped");
assertEqual(before.frameIndex, 0, "negative playback selects first frame");
const after = playbackFrameAtTime(plan, 10);
assertEqual(after.time, 1, "late playback time is clamped");
assertEqual(after.frameIndex, 3, "late playback selects last frame");
assertEqual(after.sceneProgress, 1, "late playback progress");
});
test("Camera playback resolves moving positions without mutating the scene", () => {
const movingSource = {
id: "moving",
position: [99, 99, 99],
trajectory: [
{ time: 0, position: [0, 0, 2] },
{ time: 1, position: [4, 2, 2] },
],
};
const fixedSource = { id: "fixed", position: [-1, 1, 3] };
const scene = { duration: 1, sampleRate: 1000, sources: [movingSource, fixedSource] };
const plan = createAnalysisFramePlan(scene, { fftSize: 100, targetMapHz: 4 });
const state = scenePlaybackState(scene, plan, 0.25);
assertNear(state.scene.sources[0].position[0], 1, 1e-12, "moving x");
assertNear(state.scene.sources[0].position[1], 0.5, 1e-12, "moving y");
assertEqual(state.scene.sources[1].position.join(","), "-1,1,3", "fixed source");
assertEqual(state.sourcePositions[0].sourceId, "moving", "position metadata source id");
assertEqual(state.sourcePositions[0].position.join(","), "1,0.5,2", "position metadata value");
assertEqual(movingSource.position.join(","), "99,99,99", "input source remains unchanged");
assert(state.scene !== scene && state.scene.sources[0] !== movingSource, "sampled scene is immutable");
});
test("Camera timeline rejects non-finite and invalid planning inputs", () => {
const scene = { duration: 1, sampleRate: 1000, sources: [] };
assertThrows(
() => createAnalysisFramePlan({ ...scene, duration: Number.NaN }, { fftSize: 100 }),
"scene.duration",
"NaN duration",
);
assertThrows(
() => createAnalysisFramePlan(scene, { fftSize: Number.POSITIVE_INFINITY }),
"fftSize",
"infinite FFT size",
);
assertThrows(
() => createAnalysisFramePlan(scene, { fftSize: 100, targetMapHz: 0 }),
"targetMapHz",
"zero map rate",
);
assertThrows(
() => createAnalysisFramePlan(scene, { fftSize: 100, maxFrames: 1.5 }),
"maxFrames",
"fractional frame limit",
);
assertThrows(
() => createAnalysisFramePlan(scene, { fftSize: 100, windowDuration: Number.NaN }),
"windowDuration",
"NaN analysis window",
);
const plan = createAnalysisFramePlan(scene, { fftSize: 100 });
assertThrows(() => playbackFrameAtTime(plan, Number.NaN), "time", "NaN playback time");
assertThrows(
() => scenePlaybackState({ ...scene, duration: 2 }, plan, 0.5),
"scene.duration",
"scene and plan mismatch",
);
});
test("Camera input hash is canonical across object key order", async () => {
const first = {
arrayDefinition: {
@@ -94,7 +249,14 @@ test("Camera workload counts unique overlapping FFT bins", () => {
test("Camera distance shift keeps the scene and scan on one z plane", () => {
const scene = {
sources: [
{ id: "one", position: [0.1, -0.2, 2] },
{
id: "one",
position: [0.1, -0.2, 2],
trajectory: [
{ time: 0, position: [0.1, -0.2, 2] },
{ time: 1, position: [0.6, 0.3, 2.5] },
],
},
{ id: "two", position: [-0.3, 0.4, 2.5] },
],
image: { plane: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1 } },
@@ -105,6 +267,16 @@ test("Camera distance shift keeps the scene and scan on one z plane", () => {
assertEqual(experiment.scan.z, 3.25, "scan z");
assertEqual(scene.sources[0].position.join(","), "0.1,-0.2,3.25", "first source z shift");
assertEqual(
scene.sources[0].trajectory[0].position.join(","),
"0.1,-0.2,3.25",
"first trajectory keyframe z shift",
);
assertEqual(
scene.sources[0].trajectory[1].position.join(","),
"0.6,0.3,3.75",
"later trajectory keyframe retains relative depth",
);
assertEqual(scene.sources[1].position.join(","), "-0.3,0.4,3.75", "relative source depth retained");
assertEqual(scene.image.plane.z, 3.25, "background image plane z shift");
});
@@ -147,6 +319,26 @@ test("Camera aligns an experiment to an imported scene without changing grid res
assertNear(experiment.scan.yMax, 0.45, 1e-12, "source y maximum with padding");
assertEqual(experiment.scan.columns, 32, "columns retained after source fallback");
assertEqual(experiment.scan.rows, 24, "rows retained after source fallback");
const movingScene = {
sources: [
{
id: "moving",
position: [-2, -3, 1],
trajectory: [
{ time: 0, position: [-2, -3, 1] },
{ time: 1, position: [4, 5, 9] },
],
},
{ id: "fixed", position: [0, 1, 5] },
],
};
assertEqual(alignExperimentToScene(experiment, movingScene), "source-bounds", "trajectory fallback");
assertEqual(experiment.scan.z, 5, "trajectory depths contribute to median scan depth");
assertNear(experiment.scan.xMin, -2.9, 1e-12, "trajectory x minimum with padding");
assertNear(experiment.scan.xMax, 4.9, 1e-12, "trajectory x maximum with padding");
assertNear(experiment.scan.yMin, -4.2, 1e-12, "trajectory y minimum with padding");
assertNear(experiment.scan.yMax, 6.2, 1e-12, "trajectory y maximum with padding");
});
test("Camera result document is schema-safe and linked to its snapshot", async () => {
@@ -191,6 +383,22 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
value: 1,
localizationErrorM: 0.05,
localizationErrorPixels: 0.1,
beamSpread: {
calibrated: false,
method: "connected-half-power-power-weighted-covariance",
halfPowerRatio: 0.5,
thresholdValue: 0.5,
pixelCount: 3,
centroid: [0.9, -0.9, 2],
covarianceM2: { xx: 0.04, xy: 0.01, yy: 0.02 },
ellipse: {
axisScale: "rms",
angleReference: "counterclockwise-from-positive-x",
majorRmsRadiusM: 0.21,
minorRmsRadiusM: 0.12,
angleDeg: 22.5,
},
},
}],
metrics: {
peakToSidelobeDb: 10,
@@ -232,6 +440,8 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
"only finite schema metrics are exported",
);
assertEqual(result.bands[0].peaks[0].localizationErrorPixels, 0.1, "pixel error is exported");
assertEqual(result.bands[0].peaks[0].beamSpread.pixelCount, 3, "beam spread is exported");
assertEqual(result.bands[0].peaks[0].beamSpread.calibrated, false, "beam spread is not probability");
assertEqual(result.bands[0].evaluation.matchToleranceM, 0.2, "evaluation settings are exported");
assertEqual(result.environment.scanXMin, -1, "scan bounds are recorded");
assertEqual(result.environment.fallbackCode, "webgpu-unavailable", "fallback is recorded");
+69
View File
@@ -11,6 +11,24 @@ async function loadJson(path) {
return response.json();
}
test("moving-tone sample Scene and Experiment satisfy their linked contracts", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/moving-tone.scene.json"),
loadJson("/assets/sample-scenes/moving-tone.experiment.json"),
]);
const sceneIssues = await validateScene(scene);
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
assertEqual(sceneIssues.length, 0, `moving Scene issues: ${JSON.stringify(sceneIssues)}`);
assertEqual(experimentIssues.length, 0, `moving Experiment issues: ${JSON.stringify(experimentIssues)}`);
assertEqual(experiment.sceneId, scene.id, "moving example linkage");
assert(
scene.sources.some((source) => Array.isArray(source.trajectory) && source.trajectory.length >= 2),
"moving example contains a usable trajectory",
);
});
test("192채널 기준 배열이 계약을 통과한다", async () => {
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
assertEqual(arrayDefinition.microphones.length, 192, "microphone count");
@@ -43,6 +61,57 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
});
test("Experiment 피크 검출 임계값 비율은 재현 가능하고 유효한 범위여야 한다", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
assertEqual(experiment.evaluation.detectionThresholdRatio, 0.25, "sample threshold ratio");
assertEqual(
(await validateExperiment(experiment, { arrayDefinition, scene })).length,
0,
"0.25 threshold issues",
);
experiment.evaluation.detectionThresholdRatio = 0;
let issues = await validateExperiment(experiment, { arrayDefinition, scene });
assert(
issues.some(({ path, message }) =>
path === "$.evaluation.detectionThresholdRatio" && message.includes("0보다 크고 1 이하")),
"zero threshold is rejected with actionable guidance",
);
experiment.evaluation.detectionThresholdRatio = 1.01;
issues = await validateExperiment(experiment, { arrayDefinition, scene });
assert(
issues.some(({ path }) => path === "$.evaluation.detectionThresholdRatio"),
"threshold above one is rejected",
);
});
test("이동 음원 trajectory의 keyframe 순서와 장면 시간 범위를 검증한다", async () => {
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
scene.sources[0].trajectory = [
{ time: 0, position: [-0.2, 0, 2] },
{ time: 0.125, position: [0, 0.1, 2] },
{ time: 0.25, position: [0.2, 0, 2] },
];
assertEqual((await validateScene(scene)).length, 0, "valid trajectory issues");
scene.sources[0].trajectory[2].time = 0.125;
let issues = await validateScene(scene);
assert(issues.some(({ path }) => path === "$.sources[0].trajectory[2].time"), "strictly ordered times");
scene.sources[0].trajectory[2].time = 0.3;
issues = await validateScene(scene);
assert(issues.some(({ path }) => path === "$.sources[0].trajectory[2].time"), "scene duration bound");
scene.sources[0].trajectory = [{ time: 0, position: [0, 0, 2] }];
issues = await validateScene(scene);
assert(issues.some(({ path }) => path === "$.sources[0].trajectory"), "two-keyframe minimum");
});
test("중복 마이크 id를 거부한다", async () => {
const arrayDefinition = {
schemaVersion: "1.0.0", kind: "micarrsim.array", id: "duplicate-test", name: "duplicate test", units: "m",
+142
View File
@@ -3,6 +3,7 @@ import {
evaluateDasBand,
exportBandEvaluation,
findLocalPeaks,
measureBeamSpread,
measureMainLobe,
selectResultMetrics,
} from "/packages/acoustics-core/evaluation.js";
@@ -85,6 +86,32 @@ test("복수 source와 threshold detection을 one-to-one matching한다", () =>
);
});
test("peak 검출 감도를 낮추면 약한 두 번째 음원을 선택한다", () => {
const scan = { xMin: -2, xMax: 2, yMin: -2, yMax: 2, z: 1, columns: 5, rows: 5 };
const rawMap = new Float32Array(25);
rawMap[6] = 10;
rawMap[18] = 2.5;
const options = {
rawMap,
scan,
sources: [
{ id: "strong", position: [-1, 1, 1] },
{ id: "weak", position: [1, -1, 1] },
],
matchToleranceM: 0.25,
minimumPeakDistanceM: 0,
};
const conservative = evaluateDasBand({ ...options, detectionThresholdRatio: 0.5 });
const recommended = evaluateDasBand({ ...options, detectionThresholdRatio: 0.25 });
assertEqual(conservative.detections.length, 1, "50% threshold omits the weak peak");
assertEqual(conservative.counts.falseNegative, 1, "one source remains undetected");
assertEqual(recommended.detections.length, 2, "25% threshold retains the weak peak");
assertEqual(recommended.counts.truePositive, 2, "both sources match at recommended sensitivity");
assertEqual(recommended.parameters.detectionThresholdRatio, 0.25, "selected ratio is recorded");
});
test("zero map 평가가 빈 peak와 유한한 metric을 반환한다", () => {
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 1, columns: 2, rows: 2 };
const evaluation = evaluateDasBand({
@@ -137,6 +164,111 @@ test("flat plateau와 main-lobe width가 deterministic하다", () => {
assertNear(lobe.equivalentDiameterM, 6 / Math.sqrt(Math.PI), 1e-12, "equivalent main-lobe diameter");
});
test("beam spread가 회전된 비등방성 half-power lobe의 covariance ellipse를 측정한다", () => {
const scan = { xMin: -3, xMax: 3, yMin: -3, yMax: 3, z: 2, columns: 7, rows: 7 };
const rawMap = new Float32Array(49);
const setPower = (x, y, value) => {
rawMap[(3 - y) * scan.columns + x + 3] = value;
};
setPower(0, 0, 10);
setPower(1, 1, 8);
setPower(-1, -1, 8);
setPower(2, 2, 6);
setPower(-2, -2, 6);
setPower(-1, 1, 5.5);
setPower(1, -1, 5.5);
const spread = measureBeamSpread(rawMap, scan, 24);
assertEqual(spread.calibrated, false, "beam spread is explicitly uncalibrated");
assertEqual(
spread.method,
"connected-half-power-power-weighted-covariance",
"beam spread method",
);
assertEqual(spread.pixelCount, 7, "connected half-power pixels");
assertNear(spread.thresholdValue, 5, 1e-12, "half-power threshold");
assertNear(spread.centroid[0], 0, 1e-12, "weighted centroid x");
assertNear(spread.centroid[1], 0, 1e-12, "weighted centroid y");
assertEqual(spread.centroid[2], 2, "weighted centroid z");
assertNear(spread.covarianceM2.xx, 75 / 49, 1e-12, "rotated covariance xx");
assertNear(spread.covarianceM2.xy, 53 / 49, 1e-12, "rotated covariance xy");
assertNear(spread.covarianceM2.yy, 75 / 49, 1e-12, "rotated covariance yy");
assertNear(spread.ellipse.majorRmsRadiusM, Math.sqrt(128 / 49), 1e-12, "major RMS radius");
assertNear(spread.ellipse.minorRmsRadiusM, Math.sqrt(22 / 49), 1e-12, "minor RMS radius");
assertNear(spread.ellipse.angleDeg, 45, 1e-12, "major-axis angle");
assertEqual(spread.ellipse.axisScale, "rms", "ellipse scale is explicit");
const evaluation = evaluateDasBand({
rawMap,
scan,
sources: [],
detectionThresholdRatio: 0.5,
minimumPeakDistanceM: 0,
});
assertEqual(evaluation.detections.length, 1, "single selected detection");
assertEqual(
JSON.stringify(evaluation.detections[0].beamSpread),
JSON.stringify(spread),
"selected detection exposes its beam spread",
);
});
test("beam spread가 1-row, 1-column, single-pixel lobe에서 유한하고 결정적이다", () => {
const powers = Float32Array.of(5, 8, 10, 8, 5);
const rowSpread = measureBeamSpread(
powers,
{ xMin: -2, xMax: 2, yMin: 7, yMax: 7, z: 3, columns: 5, rows: 1 },
2,
);
const columnSpread = measureBeamSpread(
powers,
{ xMin: 4, xMax: 4, yMin: 5, yMax: 9, z: 3, columns: 1, rows: 5 },
2,
);
const pointScan = { xMin: 4, xMax: 4, yMin: 7, yMax: 7, z: 3, columns: 1, rows: 1 };
const pointSpread = measureBeamSpread(Float32Array.of(10), pointScan, 0);
const silentSpread = measureBeamSpread(Float32Array.of(0), pointScan, 0);
assertEqual(rowSpread.pixelCount, 5, "one-row lobe size");
assertNear(rowSpread.covarianceM2.xx, 14 / 9, 1e-12, "one-row x variance");
assertEqual(rowSpread.covarianceM2.yy, 0, "one-row y variance");
assertNear(rowSpread.ellipse.majorRmsRadiusM, Math.sqrt(14 / 9), 1e-12, "one-row major radius");
assertEqual(rowSpread.ellipse.minorRmsRadiusM, 0, "one-row minor radius");
assertEqual(rowSpread.ellipse.angleDeg, 0, "one-row angle");
assertEqual(columnSpread.covarianceM2.xx, 0, "one-column x variance");
assertNear(columnSpread.covarianceM2.yy, 14 / 9, 1e-12, "one-column y variance");
assertNear(columnSpread.ellipse.majorRmsRadiusM, Math.sqrt(14 / 9), 1e-12, "one-column major radius");
assertEqual(columnSpread.ellipse.minorRmsRadiusM, 0, "one-column minor radius");
assertEqual(columnSpread.ellipse.angleDeg, 90, "one-column angle");
assertEqual(pointSpread.centroid.join(","), "4,7,3", "single-pixel centroid");
assertEqual(pointSpread.pixelCount, 1, "single-pixel lobe size");
assertEqual(pointSpread.ellipse.majorRmsRadiusM, 0, "single-pixel major radius");
assertEqual(pointSpread.ellipse.minorRmsRadiusM, 0, "single-pixel minor radius");
assertEqual(silentSpread.pixelCount, 0, "silent lobe has no pixels");
for (const spread of [rowSpread, columnSpread, pointSpread, silentSpread]) {
const numbers = [
spread.halfPowerRatio,
spread.thresholdValue,
spread.pixelCount,
...spread.centroid,
...Object.values(spread.covarianceM2),
spread.ellipse.majorRmsRadiusM,
spread.ellipse.minorRmsRadiusM,
spread.ellipse.angleDeg,
];
assert(numbers.every(Number.isFinite), "every degenerate beam-spread number is finite");
}
assertEqual(
JSON.stringify(measureBeamSpread(powers, { xMin: -2, xMax: 2, yMin: 7, yMax: 7, z: 3, columns: 5, rows: 1 }, 2)),
JSON.stringify(rowSpread),
"degenerate measurement is repeatable",
);
});
test("result exporter가 schema 허용 metric만 선택하고 내부 필드를 제거한다", () => {
const scan = { xMin: 0, xMax: 1, yMin: 0, yMax: 1, z: 1, columns: 2, rows: 2 };
const evaluation = evaluateDasBand({
@@ -154,6 +286,16 @@ test("result exporter가 schema 허용 metric만 선택하고 내부 필드를
assertEqual(exported.peaks[0].sourceId, "truth", "schema peak source match");
assertEqual(exported.peaks[0].localizationErrorM, 0, "schema peak localization error");
assertEqual(exported.peaks[0].localizationErrorPixels, 0, "schema peak pixel error");
assertEqual(
JSON.stringify(exported.peaks[0].beamSpread),
JSON.stringify(evaluation.detections[0].beamSpread),
"schema peak retains beam-spread descriptor",
);
assert(exported.peaks[0].beamSpread !== evaluation.detections[0].beamSpread, "beam spread is copied");
assert(
exported.peaks[0].beamSpread.covarianceM2 !== evaluation.detections[0].beamSpread.covarianceM2,
"beam-spread covariance is copied",
);
assert(!("index" in exported.peaks[0]), "internal peak index omitted");
assert(!("mainLobeWidthM" in exported.metrics), "explicit metric selection is respected");
assertEqual(exported.evaluation.version, "1.0.0", "evaluation version exported");