Compare commits
12 Commits
b03eccde50
...
fd3d98947a
| Author | SHA1 | Date | |
|---|---|---|---|
| fd3d98947a | |||
| 7cd737429a | |||
| a54aa25cd9 | |||
| 01a37eac69 | |||
| 6490bbe6fc | |||
| 6280fa4dfd | |||
| a20b55e8e2 | |||
| acb36bf15d | |||
| a7befa0270 | |||
| b678d1f9c7 | |||
| 95f2ca19a6 | |||
| 9a7401c023 |
@@ -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/>
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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가 필요합니다.");
|
||||
|
||||
@@ -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 효과나 연속적인 전파 시간 변화는 현재 모델 범위에 포함되지 않습니다.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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가 생기는 회귀 테스트로 이 부호를 고정합니다.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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`에서 추가로 검증합니다.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\run-tests.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
@@ -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 흐름
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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 수치 일치는
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user