Compare commits

...

10 Commits

Author SHA1 Message Date
Codex b03eccde50 feat: protect unsaved camera work 2026-08-28 13:09:55 +09:00
Codex 513de2b51f docs: add quick start and user guides 2026-08-28 12:47:57 +09:00
Codex 3317a03d1a feat: harden end-to-end simulation workflow 2026-08-28 12:34:11 +09:00
Codex 2ce96064cf feat: add built-in array comparison 2026-08-28 12:19:42 +09:00
Codex d04a4d9f65 feat: complete acoustic camera workflow 2026-08-28 12:11:10 +09:00
Codex d5f7530620 feat: add WebGPU DAS backend 2026-08-28 11:26:19 +09:00
Codex 08fcf578de feat: build reproducible dataset editor 2026-08-28 10:46:33 +09:00
Codex 6a1197d60e feat: implement free-field CPU beamforming 2026-08-28 09:48:34 +09:00
Codex 691a4b03ce fix: harden simulator data contracts 2026-08-28 09:21:06 +09:00
Codex 582d0013e6 feat: define simulator data contracts 2026-08-28 09:07:58 +09:00
84 changed files with 15844 additions and 104 deletions
+171 -20
View File
@@ -1,27 +1,178 @@
# micarrsim
192채널 MEMS 마이크 배열의 배치와 음향 영상 성능을 평가하기 위한 WebGPU 기반 시뮬레이터입니다.
192채널 MEMS 마이크 배열의 배치와 자유음장 DAS 음향 영상을 비교하는 로컬 브라우저
시뮬레이터입니다. Dataset Builder에서 재현 가능한 음향 장면을 만들고 Acoustic Camera에서
CPU 또는 WebGPU로 배열 응답, acoustic map과 위치 검출 지표를 계산합니다.
현재는 설계 단계입니다. 프로젝트 목표는 [`goal.md`](goal.md), 구현 로드맵과 기술 결정은
[`docs/execution-plan.md`](docs/execution-plan.md)를 참고하세요.
별도 프로그램이나 패키지를 설치하지 않습니다. 모든 앱과 계산은 이 저장소와 브라우저 안에서
실행됩니다.
## 디렉터리
## 실행 환경
```text
apps/
dataset-builder/ 음원 합성 및 ground truth 생성 UI
acoustic-camera/ 전파, DAC 빔포밍, 평가 및 시각화 UI
packages/
acoustics-core/ 공통 좌표계, 음향 모델, 평가 로직
webgpu-kernels/ WGSL 기반 steering/DAC 연산
schemas/ 배열·장면·실험 JSON Schema
assets/
array-geometries/ 마이크 배열 예제
sample-scenes/ 재배포 가능한 소규모 예제 장면
tests/
unit/ 수치 모델 단위 테스트
integration/ 앱 간 데이터 계약과 기준 시나리오 테스트
docs/ 설계 및 실행 계획
- Windows 10/11
- 최신 Microsoft Edge 또는 Google Chrome
- 저장소에 포함된 Windows PowerShell과 브라우저 외 추가 런타임 불필요
- WebGPU가 없거나 장치를 열 수 없어도 CPU backend로 실행 가능
## 가장 간단하게 시작하기
1. 저장소 최상위의 `start.cmd`를 더블클릭합니다.
2. 기본 브라우저에서 micarrsim 시작 화면이 열립니다.
3. 사용을 마치면 함께 열린 콘솔에서 `Ctrl+C`를 눌러 로컬 서버를 종료합니다.
브라우저가 자동으로 열리지 않으면 <http://127.0.0.1:4173/>을 직접 여세요. HTML module과
Worker를 사용하므로 `index.html` 파일을 탐색기에서 직접 열어서는 안 됩니다.
다른 포트를 쓰거나 브라우저를 자동으로 열지 않으려면 명령 프롬프트에서 실행합니다.
```bat
start.cmd -Port 4174 -NoBrowser
```
원본 DCASE/MIMII 데이터와 생성된 대용량 결과는 저장소에 커밋하지 않습니다.
PowerShell에서 서버를 직접 실행할 수도 있습니다.
```powershell
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.ps1 -Port 4174 -NoBrowser
```
이 명령의 `ExecutionPolicy Bypass`는 실행 중인 PowerShell 프로세스에만 적용되며 시스템 정책을
영구 변경하지 않습니다. 해당 명령을 사용했다면 <http://127.0.0.1:4174/>을 여세요.
실행 후 브라우저용 빠른 시작과 문제 해결은 <http://127.0.0.1:4173/docs/>에서도 볼 수 있습니다.
### 30초 기본 예제
1. 시작 화면에서 **Acoustic Camera**를 엽니다.
2. 기본 192채널 rectangular 배열, single-tone scene과 50×50 experiment가 준비될 때까지
기다립니다.
3. **기본 예제 실행**을 누릅니다.
4. 계산이 끝나면 overlay와 평가 지표를 확인하고 **Result JSON**을 누릅니다.
실제 완료 시간은 브라우저와 장치에 따라 다릅니다. CPU fallback에서는 WebGPU보다 오래 걸릴 수
있습니다.
## 내 장면으로 전체 흐름 실행하기
### 1. Dataset Builder에서 Scene 만들기
1. 시작 화면에서 **Dataset Builder**를 엽니다.
2. 선택적으로 배경 이미지와 오디오를 추가합니다.
3. 음원을 추가해 canvas 또는 x/y/z 입력으로 위치를 정하고, tone·noise·audio asset과 ambient를
설정합니다.
4. 상태가 **저장 가능한 장면**이면 **Scene JSON 저장**을 누릅니다.
**Preview WAV 저장**은 source와 ambient를 공간 정보 없이 하나의 mono channel로 합친 청취
확인용 파일입니다. 192채널 마이크 신호나 beamforming 결과가 아닙니다.
### 2. Acoustic Camera에서 실행하기
1. 홈으로 돌아가 **Acoustic Camera**를 엽니다.
2. 다음 순서로 입력합니다: **Scene → Array → Experiment → Assets**.
3. **Scene**에서 Builder가 저장한 `.scene.json`을 선택합니다.
4. 기본 배열을 사용하지 않을 때만 **Array**에서 `.array.json`을 선택합니다.
5. 별도 실험 문서를 사용할 때만 **Experiment**에서 `.experiment.json`을 선택합니다.
6. Scene에 이미지나 audio asset이 있다면 **Assets**에서 Builder에 사용했던 원본 파일을 다시
선택합니다. 여러 파일을 한 번에 선택할 수 있습니다.
7. 입력 검증이 통과하면 backend와 scan grid를 정하고 실행합니다.
8. 완료 후 **Result JSON**을 저장합니다.
Array와 Scene을 먼저 불러와야 Experiment의 `arrayId`와 `sceneId` 관계를 안전하게 검증할 수
있습니다. Scene을 다시 불러오면 이전 asset 연결은 해제됩니다.
Camera에서 수정한 Scene·Experiment, 완료 Result와 전체 배열 비교 기록은 각각 JSON으로 저장할 수
있습니다. 저장하지 않은 항목이나 실행 중 계산이 있으면 페이지를 닫을 때 브라우저가 확인합니다.
## Scene JSON과 로컬 assets
Scene JSON에는 원본 이미지나 오디오 byte가 들어가지 않습니다. 대신 asset id, 종류, 파일명과
SHA-256 manifest를 저장합니다. 브라우저는 새 페이지나 세션에서 로컬 파일 접근 권한을 유지할 수
없으므로 원본 파일을 다시 선택해야 합니다.
- Camera는 SHA-256으로 선택한 파일을 manifest에 다시 연결합니다.
- 같은 이름이라도 내용이 달라 hash가 일치하지 않으면 거절합니다.
- 로컬 asset 한 파일은 최대 128 MiB, 연결된 파일 합계는 최대 512 MiB입니다.
- 실제 decode되어 메모리에 유지되는 오디오 합계는 최대 192 MiB입니다.
- Scene JSON에는 원본 파일명이 기록될 수 있으므로 파일을 외부에 공유하기 전에 확인하세요.
## Backend와 scan grid
- **Auto (GPU→CPU)**: 권장값입니다. WebGPU adapter나 device를 사용할 수 없거나 지원되는 장치
한계를 넘으면 CPU로 계속합니다.
- **WebGPU 강제**: GPU 경로만 사용합니다. 장치를 열 수 없거나 한계를 넘으면 fallback하지 않고
오류를 표시합니다.
- **CPU 강제**: 비교와 호환성 확인을 위해 CPU DAS만 사용합니다.
WebGPU 경로도 신호 합성과 STFT 일부는 CPU에서 수행합니다. grid의 point 수는 50×50을 1로 보면
100×100은 4배, 200×200은 16배입니다. 처음에는 50×50을 사용하고 해상도가 필요할 때만 높이세요.
실행 시간은 microphone 수, scene 길이, FFT, 대역 폭과 장치에 함께 좌우됩니다.
## Result JSON
Result JSON은 계약 버전 `1.0.0`의 `micarrsim.result` 문서입니다. 주요 내용은 다음과 같습니다.
- 실행한 `experimentId`, `arrayId`, `sceneId`, 실제 `backend`와 생성 시각
- Array·Scene·Experiment의 canonical SHA-256인 `inputHash`
- 정규화 전 peak, 적용 scale과 clipping 수를 담은 `signalStats`
- 전체·신호 생성·beamforming 시간인 `timingMs`
- 주파수 대역별 선형 power `rawMap`, 표시용 `displayMap`, 검출 `peaks`
- 위치 오차, precision/recall, truth 반경 내 energy, peak-to-sidelobe, main-lobe 폭을 계산하기
위한 평가 파라미터와 `metrics`
평가에서 tone source는 tone 주파수가 포함되는 band의 ground truth로만 사용합니다. Audio asset과
noise source는 스펙트럼을 미리 알 수 없으므로 모든 band의 ground truth로 취급합니다. 이 가정은
결과를 실제 데이터와 해석할 때 고려해야 합니다.
## 개인정보와 모델의 한계
서버는 `127.0.0.1` loopback에만 열리고 외부 서비스로 파일을 업로드하거나 telemetry를 보내지
않습니다. 선택한 파일은 브라우저 메모리에서 읽으며 생성한 JSON/WAV는 브라우저 다운로드로만
저장됩니다.
현재 음향 모델은 직접 경로와 거리 감쇠를 사용하는 자유음장 모델입니다. 반사, 잔향, 회절,
차폐, 바람, 실제 MEMS 비선형과 장착 구조는 모델링하지 않습니다. Beamformer는 conventional
DAS이며 UI의 DAMAS와 CLEAN-SC는 아직 사용할 수 없습니다. 따라서 결과는 배열·grid·대역의 상대
비교와 소프트웨어 검증용이며, 현장 성능 인증이나 안전 판단을 대신하지 않습니다.
## 배열 예제
- `assets/array-geometries/rectangular-16x12.array.json`: 0.45 m 정사각 aperture의 16×12 기준 배열
- `assets/array-geometries/fermat-spiral-192.array.json`: 0.45 m 원형 aperture의 192채널 Fermat spiral
Camera의 **비교 배열** 선택으로 두 fixture를 같은 scene과 experiment에서 연속 실행할 수 있습니다.
상세한 생성법은 [`assets/array-geometries/README.md`](assets/array-geometries/README.md)를 참고하세요.
## 테스트와 benchmark
서버를 실행한 뒤 다음 주소를 브라우저에서 엽니다.
- 전체 browser test runner: <http://127.0.0.1:4173/tests/>
- WebGPU scaling benchmark: <http://127.0.0.1:4173/benchmarks/webgpu-scaling.html>
포트를 바꿨다면 URL의 포트도 같은 값으로 바꾸세요. 테스트 범위는
[`tests/unit/README.md`](tests/unit/README.md)와
[`tests/integration/README.md`](tests/integration/README.md), 기준 실측은
[`docs/benchmarks/README.md`](docs/benchmarks/README.md)에 설명되어 있습니다.
## 저장 형식과 개발 문서
- [`docs/data-contracts.md`](docs/data-contracts.md): Array·Scene·Experiment·Result 계약
- [`schemas/README.md`](schemas/README.md): JSON Schema 구성
- [`docs/execution-plan.md`](docs/execution-plan.md): 구현 단계와 기술 결정
- [`goal.md`](goal.md): 프로젝트 목표
원본 DCASE/MIMII 데이터, 사용자가 선택한 assets와 생성된 대용량 결과는 저장소에 커밋하지
않습니다.
## 디렉터리 구조
```text
apps/ Dataset Builder와 Acoustic Camera UI
assets/ 192채널 배열과 synthetic Scene/Experiment fixture
benchmarks/ 현재 브라우저에서 실행하는 WebGPU scaling 측정
docs/ 사용자 안내, 데이터 계약, 실행 계획과 기준 실측
packages/ 음향 수치 모델, 계약 검증, UI, WebGPU kernel
schemas/ Array·Scene·Experiment·Result JSON Schema
scripts/ 무설치 로컬 서버와 fixture 생성기
tests/ 브라우저 unit·integration·end-to-end test
start.cmd Windows 원클릭 실행 진입점
```
+98 -1
View File
@@ -1,3 +1,100 @@
# Acoustic Camera
scene과 마이크 배열을 불러와 자유음장 전파, WGSL DAS 빔포밍, overlay 및 평가를 수행하는 앱입니다.
Scene과 마이크 배열로 자유음장 microphone signal을 합성하고, STFT와 conventional DAS를 통해
주파수 대역별 acoustic map과 ground-truth 평가 지표를 만드는 브라우저 앱입니다.
## 열기와 기본 예제
저장소의 `start.cmd`를 실행한 뒤 시작 화면에서 **Acoustic Camera**를 선택하거나
<http://127.0.0.1:4173/apps/acoustic-camera/>을 엽니다. `index.html`을 직접 열지 마세요.
가장 빠른 확인 방법은 다음과 같습니다.
1. 기본 fixture가 준비될 때까지 기다립니다.
2. 192채널 rectangular 배열, single-tone scene과 50×50 grid를 그대로 둡니다.
3. **기본 예제 실행**을 누릅니다.
4. 완료 후 overlay, 위치 오차와 timing을 확인하고 **Result JSON**을 저장합니다.
## 사용자 입력 불러오기
문서는 다음 순서로 불러오는 것이 안전합니다.
1. **Scene**: `.scene.json`
2. **Array**: 선택 사항인 `.array.json`; 또는 **비교 배열** preset 사용
3. **Experiment**: 선택 사항인 `.experiment.json`
4. **Assets**: Scene이 참조하는 원본 이미지와 오디오
Scene을 불러오면 현재 Experiment의 `sceneId`와 scan plane을 Scene에 맞추고 기존 asset 연결을
해제합니다. Array를 불러오면 현재 Experiment의 `arrayId`를 맞춥니다. 별도 Experiment는 현재
Scene과 Array를 참조해야 하므로 마지막에 불러오세요.
Scene JSON에는 원본 파일 byte가 없습니다. **Assets**에서 Builder에 사용한 파일을 다시 선택하면
Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라도 내용이 바뀌면 거절됩니다.
브라우저를 다시 열거나 Scene을 바꿀 때도 재선택해야 합니다.
- asset 한 파일: 최대 128 MiB
- 연결된 asset 합계: 최대 512 MiB
- decode되어 메모리에 유지되는 오디오 합계: 최대 192 MiB
파일은 외부로 업로드되지 않고 브라우저 메모리에서만 처리됩니다.
## Backend 선택
- **Auto (GPU→CPU)**: 권장. WebGPU를 먼저 시도하고 adapter/device 부재나 지원되는 장치 한계가
있으면 CPU로 계속합니다.
- **WebGPU 강제**: GPU만 사용하며 운용 오류가 나도 CPU로 fallback하지 않습니다.
- **CPU 강제**: CPU 수치 기준이나 WebGPU가 없는 환경에서 사용합니다.
WebGPU가 감지돼도 실제 장치는 실행 시 확인합니다. 계산 중 **계산 취소**를 누르면 Worker를
종료하며, 이미 완료된 이전 결과가 있으면 그대로 남습니다.
## Grid, STFT와 대역
50×50은 2,500 points입니다. 이를 1로 보면 100×100은 4배, 200×200은 16배 points를 계산합니다.
처음에는 50×50을 권장합니다. 실행량은 microphone 수, scene 길이, FFT/hop과 선택 band의 bin
수에도 비례하므로 실제 시간 배율은 장치마다 다릅니다.
각 band는 id, 최소/최대 Hz와 overlay 색을 가집니다. Nyquist를 넘거나 겹치는 id 등 계약 오류는
실행 전에 표시됩니다. 현재 후처리는 raw DAS만 지원하며 DAMAS와 CLEAN-SC 항목은 비활성화되어
있습니다.
## 화면과 배열 비교
- acoustic overlay의 투명도와 표시할 band를 선택할 수 있습니다.
- **Ground truth**를 켜면 source 위치와 검출 peak를 비교할 수 있습니다.
- rectangular와 Fermat spiral preset을 같은 Scene/Experiment에서 연속 실행하면 **배열 비교 기록**에
backend, timing과 평가 요약이 남습니다.
- 입력을 바꾼 뒤 화면에 남은 결과는 이전 실행 snapshot이며 UI가 이전 설정 결과임을 표시합니다.
- Camera에서 거리·음속·seed를 바꾼 Scene과 scan·STFT·band를 바꾼 Experiment는 우측의 각
**JSON 저장** 버튼으로 보존할 수 있습니다.
- **전체 기록 JSON**은 세션에 남은 모든 배열 비교 요약을
`micarrsim.comparison-history` 문서로 저장합니다.
미저장 Scene·Experiment·Result·비교 기록은 우측 저장 상태에 표시됩니다. 직전 Result를 저장하지
않고 새 실행을 시작하면 교체 여부를 확인하며, 미저장 항목이나 실행 중 계산이 있는 상태에서
페이지를 닫으면 브라우저가 한 번 더 확인합니다.
## Result JSON
**Result JSON**은 검증을 통과한 `micarrsim.result` 문서만 저장합니다.
- `experimentId`, `arrayId`, `sceneId`, `createdAt`, 실제 `backend`
- 실행 입력 전체의 canonical SHA-256인 `inputHash`
- `signalStats`: 정규화 전 peak, 적용 scale, clipped sample 수
- `timingMs`: 전체, signal generation과 beamforming 시간
- band별 `rawMap` 선형 power, `displayMap`, 검출 `peaks`
- 평가 설정과 `peakToSidelobeDb`, `energyInsideTruthRatio`, `precision`, `recall`,
`mainLobeWidthM`
Tone source는 그 주파수가 band 범위 안에 있을 때만 해당 band의 ground truth입니다. Audio asset과
noise source는 스펙트럼을 사전에 판별하지 않으므로 모든 band의 ground truth로 평가합니다. Source
z가 scan z와 다르면 평면에 투영한 x/y를 사용하므로 UI 경고와 위치 오차를 함께 해석하세요.
## 해석 한계
신호 모델은 직접 경로와 거리 감쇠를 사용하는 자유음장입니다. 반사, 잔향, 회절, 차폐, 바람,
센서 비선형과 장착 구조는 포함하지 않습니다. 배열 JSON의 sensitivity/phase calibration은 적용하지만
결과는 상대 배열 비교와 알고리즘 검증용이며 실측 인증을 대신하지 않습니다.
WebGPU 성능을 별도로 측정하려면 서버 실행 후
<http://127.0.0.1:4173/benchmarks/webgpu-scaling.html>을 여세요.
+261
View File
@@ -0,0 +1,261 @@
import { decodeAudioFile, MAX_DECODED_AUDIO_BYTES } from "/packages/audio-io/browser-audio.js";
import { sha256File } from "/packages/contracts/io.js";
const MAX_FILE_BYTES = 128 * 1024 * 1024;
const MAX_BOUND_FILE_BYTES = 512 * 1024 * 1024;
function basename(path) {
return String(path).split(/[\\/]/).pop();
}
function kindForFile(file) {
if (file.type.startsWith("image/") || /\.(?:png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name)) return "image";
if (file.type.startsWith("audio/") || /\.(?:wav|flac|mp3|ogg|m4a|aac)$/i.test(file.name)) return "audio";
return null;
}
function referencedAssetIds(scene) {
const ids = new Set();
if (scene.image?.assetId) ids.add(scene.image.assetId);
for (const entry of [...scene.sources, ...(scene.ambient ?? [])]) {
if (entry.signal?.type === "asset") ids.add(entry.signal.assetId);
}
return ids;
}
function audioAssetIds(scene) {
const ids = new Set();
for (const entry of [...scene.sources, ...(scene.ambient ?? [])]) {
if (entry.signal?.type === "asset") ids.add(entry.signal.assetId);
}
return ids;
}
async function loadImageElement(file) {
const url = URL.createObjectURL(file);
const image = new Image();
image.decoding = "async";
try {
await new Promise((resolve, reject) => {
image.addEventListener("load", resolve, { once: true });
image.addEventListener("error", () => reject(new Error(`${file.name} 이미지를 decode하지 못했습니다.`)), {
once: true,
});
image.src = url;
});
return { image, url };
} catch (error) {
URL.revokeObjectURL(url);
throw error;
}
}
function waitForDecode(decodePromise, signal) {
if (!signal) return decodePromise;
if (signal.aborted) return Promise.reject(new DOMException("오디오 decode가 취소됐습니다.", "AbortError"));
return new Promise((resolve, reject) => {
const onAbort = () => reject(new DOMException("오디오 decode가 취소됐습니다.", "AbortError"));
signal.addEventListener("abort", onAbort, { once: true });
decodePromise.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(error) => {
signal.removeEventListener("abort", onAbort);
reject(error);
},
);
});
}
export function createCameraAssetRuntime(onChange = () => {}) {
const files = new Map();
const errors = new Map();
let imageElement = null;
let imageUrl = null;
let generation = 0;
function revokeImage() {
if (imageUrl) URL.revokeObjectURL(imageUrl);
imageUrl = null;
imageElement = null;
}
function reset() {
generation += 1;
files.clear();
errors.clear();
revokeImage();
onChange();
}
function totalBoundBytes(exceptIds = new Set()) {
let total = 0;
const countedFiles = new Set();
for (const [assetId, file] of files) {
if (!exceptIds.has(assetId) && !countedFiles.has(file)) {
total += file.size;
countedFiles.add(file);
}
}
return total;
}
async function refreshImage(scene, expectedGeneration) {
const imageAssetId = scene.image?.assetId;
if (!imageAssetId || !files.has(imageAssetId)) {
revokeImage();
return;
}
const file = files.get(imageAssetId);
const loaded = await loadImageElement(file);
if (generation !== expectedGeneration) {
URL.revokeObjectURL(loaded.url);
return;
}
revokeImage();
imageElement = loaded.image;
imageUrl = loaded.url;
}
async function bindFiles(selectedFiles, scene) {
const currentGeneration = ++generation;
const descriptors = scene.assets ?? [];
const bound = [];
const rejected = [];
for (const file of selectedFiles) {
try {
if (file.size > MAX_FILE_BYTES) {
throw new Error(`${file.name} 크기가 128 MiB 파일 한도를 넘습니다.`);
}
const kind = kindForFile(file);
if (!kind) throw new Error(`${file.name}의 image/audio 종류를 판별할 수 없습니다.`);
const hash = await sha256File(file);
if (generation !== currentGeneration) return { bound, rejected, stale: true };
const candidates = descriptors.filter((descriptor) => descriptor.kind === kind);
let matches = candidates.filter((descriptor) => descriptor.sha256 === hash);
if (matches.length === 0) {
matches = candidates.filter(
(descriptor) => !descriptor.sha256 && basename(descriptor.fileName) === file.name,
);
}
if (matches.length === 0) {
const conflict = candidates.some(
(descriptor) => basename(descriptor.fileName) === file.name && descriptor.sha256 !== hash,
);
throw new Error(
conflict
? `${file.name}의 SHA-256이 manifest와 다릅니다.`
: `${file.name}에 대응하는 ${kind} asset descriptor가 없습니다.`,
);
}
const replacingIds = new Set(matches.map((descriptor) => descriptor.id));
const nextTotal = totalBoundBytes(replacingIds) + file.size;
if (nextTotal > MAX_BOUND_FILE_BYTES) {
throw new Error(`연결 파일 합계가 512 MiB 한도를 넘습니다.`);
}
for (const descriptor of matches) {
descriptor.sha256 ??= hash;
files.set(descriptor.id, file);
errors.delete(descriptor.id);
bound.push(descriptor.id);
}
} catch (error) {
rejected.push({ fileName: file.name, message: error instanceof Error ? error.message : String(error) });
}
}
try {
await refreshImage(scene, currentGeneration);
} catch (error) {
if (scene.image?.assetId) errors.set(scene.image.assetId, error.message);
rejected.push({ fileName: files.get(scene.image?.assetId)?.name ?? "image", message: error.message });
}
if (generation === currentGeneration) onChange();
return { bound: [...new Set(bound)], rejected, stale: generation !== currentGeneration };
}
function statuses(scene) {
const referenced = referencedAssetIds(scene);
return (scene.assets ?? [])
.filter((descriptor) => referenced.has(descriptor.id))
.map((descriptor) => ({
descriptor,
file: files.get(descriptor.id) ?? null,
error: errors.get(descriptor.id) ?? null,
ready: files.has(descriptor.id) && !errors.has(descriptor.id),
}));
}
function missingAssetIds(scene) {
return statuses(scene).filter((status) => !status.ready).map(({ descriptor }) => descriptor.id);
}
async function decodeAudioAssets(scene, signal, onProgress = () => {}) {
const ids = [...audioAssetIds(scene)];
const entries = [...scene.sources, ...(scene.ambient ?? [])];
const decoded = new Map();
const decodedByFile = new Map();
let retainedBytes = 0;
for (let index = 0; index < ids.length; index += 1) {
const assetId = ids[index];
const file = files.get(assetId);
if (!file) throw new Error(`오디오 원본 asset을 연결하세요: ${assetId}`);
onProgress({ completed: index, total: ids.length, assetId, fileName: file.name });
let audio = decodedByFile.get(file);
if (!audio) {
const remaining = MAX_DECODED_AUDIO_BYTES - retainedBytes;
audio = await waitForDecode(
decodeAudioFile(file, scene.sampleRate, remaining, signal),
signal,
);
decodedByFile.set(file, audio);
retainedBytes += audio.channels.reduce((sum, channel) => sum + channel.byteLength, 0);
}
for (const entry of entries) {
if (entry.signal?.type !== "asset" || entry.signal.assetId !== assetId) continue;
const channel = entry.signal.channel ?? 0;
if (!audio.channels[channel]) {
throw new Error(
`${assetId}은 ${audio.channels.length}채널 오디오이므로 요청한 channel ${channel}을 사용할 수 없습니다.`,
);
}
}
decoded.set(assetId, audio);
}
onProgress({ completed: ids.length, total: ids.length });
return decoded;
}
function transferListForAudio(assetBuffers) {
const transfer = [];
const seen = new Set();
for (const audio of assetBuffers.values()) {
for (const channel of audio.channels) {
if (!seen.has(channel.buffer)) {
seen.add(channel.buffer);
transfer.push(channel.buffer);
}
}
}
return transfer;
}
return {
bindFiles,
decodeAudioAssets,
get imageElement() {
return imageElement;
},
missingAssetIds,
reset,
statuses,
transferListForAudio,
dispose() {
generation += 1;
revokeImage();
files.clear();
errors.clear();
},
};
}
+129 -16
View File
@@ -3,30 +3,143 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="micarrsim WebGPU acoustic camera simulator" />
<meta name="description" content="192채널 WebGPU acoustic camera simulator" />
<title>Acoustic Camera · micarrsim</title>
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
<link rel="stylesheet" href="/apps/acoustic-camera/styles.css" />
</head>
<body>
<div class="app-shell">
<header class="app-header">
<a class="app-brand" href="/">
<strong>micarrsim</strong>
<span>Acoustic Camera</span>
</a>
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
<div class="app-shell camera-shell">
<header class="app-header camera-header">
<a class="app-brand" href="/"><strong>micarrsim</strong><span>Acoustic Camera</span></a>
<div class="header-status">
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
<span class="run-status" id="run-status">기본 fixture 준비 중…</span>
</div>
<div class="header-actions">
<button class="button button--ghost is-hidden" id="cancel-button" type="button">계산 취소</button>
<button class="button button--ghost" id="export-result-button" type="button" disabled>Result JSON</button>
<button class="button button--primary" id="run-button" type="button" disabled>▶ 기본 예제 실행</button>
</div>
</header>
<main class="app-main">
<section class="empty-state">
<p class="eyebrow">Beamforming laboratory</p>
<h1>배열 성능 비교하기</h1>
<p>
이 화면에서 scene과 192채널 배열을 불러와 자유음장 전달 신호를 만들고 주파수 대역별
DAS acoustic image를 ground truth와 비교하게 됩니다.
</p>
<main class="camera-layout">
<section class="scene-workspace" aria-label="Acoustic image workspace">
<div class="workspace-heading">
<div>
<p class="eyebrow">Ground truth + acoustic overlay</p>
<h1 id="workspace-title">음향 장면</h1>
<p class="workspace-meta" id="scene-meta">장면을 불러오고 있습니다.</p>
</div>
<div class="workspace-import">
<div class="workspace-tools" aria-label="입력 파일">
<label class="button button--ghost file-button" title="1. Scene JSON을 먼저 선택하세요.">Scene<input id="scene-input" type="file" accept="application/json,.json" /></label>
<label class="button button--ghost file-button" title="2. 사용자 배열이 있을 때 Array JSON을 선택하세요.">Array<input id="array-input" type="file" accept="application/json,.json" /></label>
<label class="button button--ghost file-button" title="3. 현재 Scene과 Array를 참조하는 Experiment JSON을 선택하세요.">Experiment<input id="experiment-input" type="file" accept="application/json,.json" /></label>
<label class="button button--ghost file-button" title="4. Scene에 사용한 원본 파일을 다시 선택하세요.">Assets<input id="asset-input" type="file" accept="audio/*,image/*,.wav,.flac,.mp3,.ogg,.m4a,.png,.jpg,.jpeg,.webp,.svg" multiple /></label>
</div>
<p class="import-order">권장 순서 · Scene → Array → Experiment → Assets</p>
</div>
</div>
<div class="scene-canvas-card">
<canvas id="scene-canvas" aria-label="Ground truth와 주파수별 acoustic image"></canvas>
<div class="canvas-message" id="canvas-message"><strong>기본 synthetic scene이 준비됩니다.</strong><span>실행하면 주파수별 acoustic image가 이 평면에 겹쳐집니다.</span></div>
<div class="canvas-badge" id="canvas-badge">결과 없음</div>
</div>
<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"><input id="show-truth" type="checkbox" checked /><span>Ground truth</span></label>
</div>
<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>
<div class="metric-grid is-hidden" id="metric-grid"></div>
</section>
<section class="history-section">
<div class="section-heading"><div><p class="eyebrow">Same-scene comparison</p><h2>배열 비교 기록</h2></div><div class="history-actions"><button class="text-button" id="export-history-button" type="button" disabled>전체 기록 JSON</button><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div></div>
<p class="section-copy">비교 배열 또는 Array JSON을 바꿔 다시 실행하면 같은 scene·scan·STFT 결과를 이 세션에서 나란히 비교합니다.</p>
<div class="history-list" id="history-list"></div>
</section>
</section>
<aside class="control-panel" aria-label="Simulation parameters">
<section class="array-card">
<div class="array-heading"><div><p class="eyebrow">Microphone geometry</p><h2 id="array-title">배열</h2></div><strong id="microphone-count">— ch</strong></div>
<label class="array-preset-field"><span>비교 배열</span><select id="array-preset"><option value="rectangular-16x12">16×12 rectangular · 기준</option><option value="fermat-spiral-192">Fermat spiral · 비교</option><option value="custom">불러온 Array JSON</option></select></label>
<canvas id="array-canvas" aria-label="마이크 배열 평면도"></canvas>
<p id="array-meta">배열을 불러오고 있습니다.</p>
</section>
<section class="asset-card">
<div class="section-title"><span>INPUT</span><h2>장면과 assets</h2></div>
<div class="source-summary" id="source-summary"></div>
<div class="asset-list" id="asset-list"></div>
<a class="builder-link" href="/apps/dataset-builder/">음원 위치·ambient 편집은 Dataset Builder에서 →</a>
</section>
<section class="control-section">
<div class="section-title"><span>01</span><h2>실험</h2></div>
<label class="field field--wide"><span>이름</span><input id="experiment-name" type="text" maxlength="120" /></label>
<label class="field field--wide"><span>Experiment ID</span><input id="experiment-id" type="text" maxlength="120" /></label>
<div class="field-grid">
<label class="field"><span>Scene 거리 z (m)</span><input id="scene-distance" type="number" min="0.01" step="0.1" /></label>
<label class="field"><span>음속 (m/s)</span><input id="speed-of-sound" type="number" min="250" max="400" step="0.1" /></label>
<label class="field"><span>Seed</span><input id="scene-seed" type="number" min="0" max="4294967295" step="1" /></label>
<label class="field"><span>Backend</span><select id="backend"><option value="auto">Auto (GPU→CPU)</option><option value="webgpu">WebGPU 강제</option><option value="cpu">CPU 강제</option></select></label>
</div>
</section>
<section class="control-section">
<div class="section-title"><span>02</span><h2>Scan grid</h2></div>
<label class="field field--wide"><span>Preset</span><select id="grid-preset"><option value="50">50×50 · 기준</option><option value="100">100×100 · 4× points</option><option value="200">200×200 · 16× points</option><option value="custom">직접 입력</option></select></label>
<div class="field-grid">
<label class="field"><span>Columns</span><input id="scan-columns" type="number" min="8" max="512" step="1" /></label>
<label class="field"><span>Rows</span><input id="scan-rows" type="number" min="8" max="512" step="1" /></label>
<label class="field"><span>x min (m)</span><input id="scan-x-min" type="number" step="0.05" /></label>
<label class="field"><span>x max (m)</span><input id="scan-x-max" type="number" step="0.05" /></label>
<label class="field"><span>y min (m)</span><input id="scan-y-min" type="number" step="0.05" /></label>
<label class="field"><span>y max (m)</span><input id="scan-y-max" type="number" step="0.05" /></label>
</div>
<p class="workload-summary" id="workload-summary">연산량 계산 중…</p>
</section>
<section class="control-section">
<div class="section-title"><span>03</span><h2>STFT와 표시</h2></div>
<div class="field-grid">
<label class="field"><span>FFT size</span><select id="fft-size"><option>128</option><option>256</option><option selected>512</option><option>1024</option><option>2048</option><option>4096</option></select></label>
<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 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>
<section class="control-section">
<div class="section-title section-title--spread"><div><span>04</span><h2>주파수 대역</h2></div><button class="mini-button" id="add-band-button" type="button">+ 대역</button></div>
<div class="band-editor-list" id="band-list"></div>
</section>
<section class="output-card">
<div class="output-actions">
<button class="button button--ghost" id="export-scene-button" type="button">Scene JSON 저장</button>
<button class="button button--ghost" id="export-experiment-button" type="button">Experiment JSON 저장</button>
</div>
<p class="output-save-state" id="output-save-state">저장하지 않은 변경 없음</p>
</section>
<section class="validation-card" aria-live="polite">
<div class="validation-dot" id="validation-dot"></div>
<div><strong id="validation-title">검증 대기</strong><p id="validation-message">입력을 준비하고 있습니다.</p></div>
</section>
</aside>
</main>
</div>
<script type="module" src="/packages/ui/runtime-status.js"></script>
<script type="module" src="/apps/acoustic-camera/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
const RESULT_METRIC_KEYS = [
"peakToSidelobeDb",
"energyInsideTruthRatio",
"precision",
"recall",
"mainLobeWidthM",
];
function sortForCanonicalJson(value) {
if (Array.isArray(value)) return value.map(sortForCanonicalJson);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value)
.filter((key) => value[key] !== undefined)
.sort()
.map((key) => [key, sortForCanonicalJson(value[key])]),
);
}
return value;
}
export function canonicalStringify(value) {
return JSON.stringify(sortForCanonicalJson(value));
}
export async function hashSimulationInputs({ arrayDefinition, scene, experiment }) {
const canonical = canonicalStringify({ array: arrayDefinition, experiment, scene });
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
return [...new Uint8Array(digest)]
.map((value) => value.toString(16).padStart(2, "0"))
.join("");
}
export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
const frameCount = getFrameCount(
Math.ceil(scene.duration * scene.sampleRate),
experiment.stft.fftSize,
experiment.stft.hopSize,
);
const bandBins = experiment.frequencyBands.map((band) =>
binsForBand(band, scene.sampleRate, experiment.stft.fftSize),
);
const selectedBinCount = new Set(bandBins.flat()).size;
const pointCount = experiment.scan.columns * experiment.scan.rows;
const microphoneCount = arrayDefinition.microphones.length;
const complexMacCount = pointCount * microphoneCount * frameCount * selectedBinCount;
return {
pointCount,
microphoneCount,
frameCount,
selectedBinCount,
bandBins,
complexMacCount,
scalingVs50: pointCount / 2500,
distanceMiB: (pointCount * microphoneCount * 4) / 1024 ** 2,
};
}
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;
if (scene.image?.plane) scene.image.plane.z += delta;
}
export function alignExperimentToScene(experiment, scene) {
const plane = scene.image?.plane;
if (plane) {
Object.assign(experiment.scan, {
z: plane.z,
xMin: plane.xMin,
xMax: plane.xMax,
yMin: plane.yMin,
yMax: plane.yMax,
});
return "image-plane";
}
const positions = (scene.sources ?? [])
.map((source) => source.position)
.filter((position) =>
Array.isArray(position) &&
position.length === 3 &&
position.every(Number.isFinite),
);
if (positions.length === 0) return "unchanged";
const paddedBounds = (values) => {
const minimum = Math.min(...values);
const maximum = Math.max(...values);
const padding = Math.max(0.25, (maximum - minimum) * 0.15);
return [minimum - padding, maximum + padding];
};
const median = (values) => {
const sorted = [...values].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[middle];
};
const [xMin, xMax] = paddedBounds(positions.map((position) => position[0]));
const [yMin, yMax] = paddedBounds(positions.map((position) => position[1]));
Object.assign(experiment.scan, {
z: median(positions.map((position) => position[2])),
xMin,
xMax,
yMin,
yMax,
});
return "source-bounds";
}
function finiteMetricSubset(metrics = {}) {
return Object.fromEntries(
RESULT_METRIC_KEYS
.filter((key) => Number.isFinite(metrics[key]))
.map((key) => [key, metrics[key]]),
);
}
function peakForResult(peak) {
const result = {
position: peak.position.map(Number),
value: Number(peak.value),
};
if (peak.sourceId) result.sourceId = peak.sourceId;
if (Number.isFinite(peak.localizationErrorM)) result.localizationErrorM = peak.localizationErrorM;
if (Number.isFinite(peak.localizationErrorPixels)) {
result.localizationErrorPixels = peak.localizationErrorPixels;
}
return result;
}
function environmentForResult({ arrayDefinition, experiment, run }) {
const environment = {
userAgent: navigator.userAgent,
requestedBackend: experiment.beamforming.backend,
microphoneCount: arrayDefinition.microphones.length,
gridPoints: experiment.scan.rows * experiment.scan.columns,
};
for (const [key, value] of Object.entries({
gridRows: experiment.scan.rows,
gridColumns: experiment.scan.columns,
scanXMin: experiment.scan.xMin,
scanXMax: experiment.scan.xMax,
scanYMin: experiment.scan.yMin,
scanYMax: experiment.scan.yMax,
scanZ: experiment.scan.z,
})) {
if (Number.isFinite(value)) environment[key] = value;
}
if (run.fallback?.code) environment.fallbackCode = run.fallback.code;
if (run.fallback?.message) environment.fallbackMessage = run.fallback.message;
const adapter = run.gpu?.adapterInfo;
if (adapter?.vendor) environment.gpuVendor = adapter.vendor;
if (adapter?.architecture) environment.gpuArchitecture = adapter.architecture;
if (adapter?.device) environment.gpuDevice = adapter.device;
if (run.gpu?.timingKind) environment.gpuTimingKind = run.gpu.timingKind;
return environment;
}
export function createResultDocument(options) {
const {
arrayDefinition,
scene,
experiment,
run,
evaluations,
inputHash,
createdAt = new Date().toISOString(),
} = options;
return {
schemaVersion: "1.0.0",
kind: "micarrsim.result",
experimentId: experiment.id,
arrayId: arrayDefinition.id,
sceneId: scene.id,
createdAt,
backend: run.backend,
inputHash,
signalStats: {
preNormalizationPeak: run.signalStats.preNormalizationPeak,
appliedScale: run.signalStats.appliedScale,
clippedSampleCount: run.signalStats.clippedSampleCount,
},
timingMs: {
total: run.timingMs.total,
signalGeneration: run.timingMs.signalGeneration,
beamforming: run.timingMs.beamforming,
},
environment: environmentForResult({ arrayDefinition, experiment, run }),
bands: run.bands.map((band) => {
const evaluation = evaluations.get(band.id) ?? { peaks: [], metrics: {} };
const bandResult = {
id: band.id,
color: band.color,
rawMap: {
rows: experiment.scan.rows,
columns: experiment.scan.columns,
scale: "power",
values: Array.from(band.rawMap),
},
displayMap: {
rows: experiment.scan.rows,
columns: experiment.scan.columns,
scale: band.displayScale,
values: Array.from(band.displayMap),
},
peaks: evaluation.peaks.map(peakForResult),
metrics: finiteMetricSubset(evaluation.metrics),
};
if (evaluation.evaluation) bandResult.evaluation = { ...evaluation.evaluation };
return bandResult;
}),
};
}
export function createHistorySummary({ arrayDefinition, scene, experiment, run, evaluations, inputHash }) {
const firstBand = run.bands[0];
const evaluation = evaluations.get(firstBand.id);
const firstMatchedPeak = evaluation?.peaks.find((peak) => peak.sourceId) ?? evaluation?.peaks[0];
return {
id: `${Date.now()}-${inputHash.slice(0, 8)}`,
createdAt: new Date().toISOString(),
arrayId: arrayDefinition.id,
arrayName: arrayDefinition.name,
sceneId: scene.id,
experimentId: experiment.id,
backend: run.backend,
pointCount: experiment.scan.rows * experiment.scan.columns,
totalMs: run.timingMs.total,
localizationErrorM: firstMatchedPeak?.localizationErrorM ?? null,
peakToSidelobeDb: evaluation?.metrics?.peakToSidelobeDb ?? null,
inputHash,
};
}
export function safeFileStem(value, fallback = "micarrsim") {
return String(value || fallback)
.toLowerCase()
.normalize("NFKD")
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || fallback;
}
+82
View File
@@ -0,0 +1,82 @@
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js";
function post(type, jobId, payload = {}, transfer = []) {
self.postMessage({ type, jobId, ...payload }, transfer);
}
function serializeError(error) {
return {
name: error?.name ?? "Error",
code: error?.code ?? "",
message: error instanceof Error ? error.message : String(error),
stack: error?.stack ?? "",
causes: error instanceof AggregateError
? error.errors.map((cause) => ({ name: cause?.name, code: cause?.code, message: cause?.message }))
: [],
};
}
self.addEventListener("message", async (event) => {
if (event.data?.type !== "run") return;
const { jobId, scene, arrayDefinition, experiment, assetBuffers } = event.data;
const totalStartedAt = performance.now();
try {
post("progress", jobId, { stage: "signal", message: "192채널 자유음장 신호를 합성하고 있습니다…" });
const signalStartedAt = performance.now();
const synthesis = synthesizeMicrophoneSignals({
scene,
arrayDefinition,
assetBuffers,
minimumDistance: experiment.beamforming.minimumDistance,
});
const signalGeneration = performance.now() - signalStartedAt;
post("progress", jobId, {
stage: "signal-complete",
message: `마이크 신호 합성 완료 · ${signalGeneration.toFixed(0)} ms`,
});
const beamformingStartedAt = performance.now();
const das = await computeDasWithBackend({
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
scene,
experiment,
onFallback: ({ error }) =>
post("fallback", jobId, {
code: error.code,
message: error.message,
}),
onProgress: (progress) => post("progress", jobId, { stage: progress.stage, progress }),
});
const beamforming = performance.now() - beamformingStartedAt;
const transfer = [];
for (const band of das.bands) {
transfer.push(band.rawMap.buffer, band.displayMap.buffer);
}
post(
"result",
jobId,
{
run: {
...das,
signalStats: {
preNormalizationPeak: synthesis.preNormalizationPeak,
appliedScale: synthesis.appliedScale,
clippedSampleCount: synthesis.clippedSampleCount,
peak: synthesis.peak,
},
timingMs: {
total: performance.now() - totalStartedAt,
signalGeneration,
beamforming,
},
},
},
transfer,
);
} catch (error) {
post("error", jobId, { error: serializeError(error) });
}
});
+131
View File
@@ -0,0 +1,131 @@
.camera-shell { min-width: 320px; }
.camera-header { position: sticky; z-index: 30; top: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 18px; }
.header-status, .header-actions, .workspace-tools, .overlay-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; min-width: 0; }
.header-status { justify-content: center; }
.run-status { overflow: hidden; max-width: 420px; color: var(--muted); font-size: .72rem; text-overflow: ellipsis; white-space: nowrap; }
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 9px 14px; border: 1px solid var(--line); border-radius: 11px; color: var(--text); background: var(--surface-raised); cursor: pointer; font-size: .8rem; font-weight: 720; transition: border-color 140ms ease, background 140ms ease, transform 140ms ease; }
.button:hover, .button:focus-visible, .mini-button:hover, .mini-button:focus-visible { border-color: var(--mint); outline: none; transform: translateY(-1px); }
.button:disabled { cursor: wait; opacity: .48; transform: none; }
.button--primary { border-color: rgb(101 242 191 / 55%); color: #06130f; background: var(--mint); }
.button--ghost { background: rgb(255 255 255 / 3%); }
.button--full { width: 100%; }
.file-button input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); }
.camera-layout { display: grid; grid-template-columns: minmax(600px, 1fr) minmax(350px, 410px); min-height: calc(100vh - 65px); }
.scene-workspace { min-width: 0; padding: clamp(22px, 3vw, 42px); }
.workspace-heading, .section-heading, .array-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; }
.workspace-heading { margin-bottom: 20px; }
.workspace-heading .eyebrow, .section-heading .eyebrow, .array-heading .eyebrow { margin-bottom: 7px; }
.workspace-heading h1 { margin-bottom: 4px; font-size: clamp(1.9rem, 4vw, 3.2rem); letter-spacing: -.055em; }
.workspace-meta, .section-copy { margin: 0; color: var(--muted); font-size: .76rem; line-height: 1.55; }
.workspace-import { display: grid; gap: 7px; justify-items: end; }
.workspace-tools { justify-content: flex-end; }
.import-order { margin: 0; color: var(--muted); font-size: .64rem; }
.scene-canvas-card { position: relative; overflow: hidden; min-height: 520px; border: 1px solid var(--line); border-radius: 22px; background: #07110f; box-shadow: var(--shadow); }
#scene-canvas { display: block; width: 100%; height: clamp(520px, 64vh, 760px); }
.canvas-message { position: absolute; top: 32px; left: 50%; display: grid; width: min(440px, calc(100% - 48px)); gap: 6px; color: var(--muted); text-align: center; pointer-events: none; transform: translateX(-50%); }
.canvas-message strong { color: #cfe2db; font-size: .9rem; }
.canvas-message span { font-size: .74rem; }
.canvas-message.is-hidden { display: none; }
.canvas-badge { position: absolute; right: 14px; bottom: 14px; max-width: calc(100% - 28px); overflow: hidden; padding: 7px 10px; border: 1px solid rgb(255 255 255 / 10%); border-radius: 999px; color: #d7e8e2; background: rgb(8 17 15 / 80%); backdrop-filter: blur(10px); font: 650 .7rem ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; }
.overlay-toolbar { justify-content: space-between; margin-top: 12px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 14px; background: rgb(255 255 255 / 2%); }
.band-legend { display: flex; flex: 1 1 360px; flex-wrap: wrap; gap: 7px; }
.legend-toggle { display: inline-flex; align-items: center; gap: 7px; padding: 6px 9px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: #0b1714; cursor: pointer; font-size: .68rem; }
.legend-toggle input { accent-color: var(--band-color, var(--mint)); }
.legend-swatch { width: 9px; height: 9px; border-radius: 50%; background: var(--band-color, var(--mint)); }
.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); }
.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; }
.empty-result { margin-top: 14px; padding: 18px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); font-size: .78rem; line-height: 1.6; text-align: center; }
.metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: 10px; margin-top: 14px; }
.metric-card { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 13px; background: #0b1714; }
.metric-card span { display: block; margin-bottom: 7px; color: var(--muted); font-size: .66rem; }
.metric-card strong { display: block; overflow: hidden; font-size: 1.15rem; letter-spacing: -.03em; text-overflow: ellipsis; white-space: nowrap; }
.metric-card small { display: block; margin-top: 5px; color: var(--muted); font-size: .62rem; }
.text-button, .mini-button { padding: 5px 7px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); background: transparent; cursor: pointer; font-size: .68rem; }
.history-list { display: grid; gap: 8px; margin-top: 14px; }
.history-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; }
.history-empty { padding: 12px; color: var(--muted); font-size: .72rem; text-align: center; }
.history-card { display: grid; grid-template-columns: minmax(150px, 1.5fr) repeat(4, minmax(85px, .75fr)); gap: 10px; align-items: center; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: #0b1714; }
.history-card strong, .history-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-card strong { font-size: .78rem; }
.history-card span { color: var(--muted); font-size: .67rem; }
.control-panel { position: sticky; top: 65px; align-self: start; overflow-y: auto; max-height: calc(100vh - 65px); padding: 16px; border-left: 1px solid var(--line); background: rgb(12 24 21 / 90%); backdrop-filter: blur(18px); }
.array-card, .asset-card, .control-section, .output-card, .validation-card { padding: 16px; border: 1px solid var(--line); border-radius: 16px; background: rgb(255 255 255 / 2%); }
.array-card + section, .control-panel section + section { margin-top: 11px; }
.array-heading { align-items: flex-start; }
.array-heading h2 { margin: 0; font-size: .95rem; }
.array-heading > strong { color: var(--mint); font: 750 .78rem ui-monospace, SFMono-Regular, Consolas, monospace; }
.array-preset-field { display: grid; gap: 6px; margin-top: 12px; }
.array-preset-field span { color: var(--muted); font-size: .66rem; font-weight: 650; }
.array-preset-field select { width: 100%; min-height: 37px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; }
.array-preset-field select:focus { border-color: var(--mint); outline: 2px solid rgb(101 242 191 / 11%); }
#array-canvas { display: block; width: 100%; height: 265px; margin-top: 10px; border: 1px solid var(--line); border-radius: 12px; background: #07110f; }
#array-meta { margin: 9px 0 0; color: var(--muted); font: 600 .66rem ui-monospace, SFMono-Regular, Consolas, monospace; }
.section-title { display: flex; align-items: center; gap: 9px; margin-bottom: 13px; }
.section-title > span, .section-title > div > span { color: var(--mint); font: 750 .66rem ui-monospace, SFMono-Regular, Consolas, monospace; }
.section-title h2 { margin: 0; font-size: .88rem; }
.section-title--spread { justify-content: space-between; }
.section-title--spread > div { display: flex; align-items: center; gap: 9px; }
.source-summary { display: grid; gap: 7px; }
.source-line { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: .69rem; }
.source-line strong { overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
.asset-list { display: grid; gap: 7px; margin-top: 10px; }
.asset-status { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--muted); font-size: .66rem; }
.asset-status strong { overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
.asset-status.is-ready span { color: var(--mint); }
.asset-status.is-error span { color: var(--danger); }
.asset-empty { color: var(--muted); font-size: .68rem; }
.builder-link { display: block; margin-top: 11px; color: var(--cyan); font-size: .68rem; text-decoration: none; }
.output-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.output-save-state { margin: 9px 0 0; color: var(--muted); font-size: .65rem; text-align: center; }
.output-save-state.is-dirty { color: var(--amber); }
.field { display: grid; min-width: 0; gap: 6px; }
.field > span { color: var(--muted); font-size: .67rem; font-weight: 650; }
.field input, .field select, .band-editor input { width: 100%; min-height: 37px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; }
.field input:focus, .field select:focus, .band-editor input:focus { border-color: var(--mint); outline: 2px solid rgb(101 242 191 / 11%); }
.field--wide + .field--wide, .field--wide + .field-grid { margin-top: 9px; }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 9px; }
.workload-summary { margin: 11px 0 0; padding: 10px; border-radius: 9px; color: var(--muted); background: #0b1714; font: 600 .66rem/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; }
.workload-summary.is-warning { color: var(--amber); }
.band-editor-list { display: grid; gap: 9px; }
.band-editor { display: grid; grid-template-columns: minmax(70px, 1fr) minmax(62px, .8fr) minmax(62px, .8fr) 34px; gap: 6px; padding: 9px; border: 1px solid var(--line); border-radius: 11px; background: #0b1714; }
.band-editor input { min-width: 0; font-size: .68rem; }
.band-editor input[type="color"] { padding: 3px; }
.band-editor__remove { border: 0; border-radius: 8px; color: var(--danger); background: rgb(255 123 115 / 8%); cursor: pointer; }
.mini-button { border-color: var(--line); }
.validation-card { display: flex; align-items: flex-start; gap: 11px; }
.validation-dot { width: 10px; height: 10px; flex: 0 0 auto; margin-top: 4px; border-radius: 50%; background: var(--muted); box-shadow: 0 0 0 5px rgb(145 170 162 / 8%); }
.validation-card strong { font-size: .8rem; }
.validation-card p { margin: 4px 0 0; color: var(--muted); font-size: .69rem; line-height: 1.5; }
.validation-card.is-valid .validation-dot { background: var(--mint); box-shadow: 0 0 0 5px rgb(101 242 191 / 10%); }
.validation-card.is-invalid .validation-dot { background: var(--danger); box-shadow: 0 0 0 5px rgb(255 123 115 / 10%); }
.validation-card.is-warning .validation-dot { background: var(--amber); box-shadow: 0 0 0 5px rgb(255 197 110 / 10%); }
.is-hidden { display: none !important; }
@media (max-width: 1120px) {
.camera-header { grid-template-columns: auto 1fr; }
.header-status { justify-content: flex-end; }
.header-actions { grid-column: 1 / -1; justify-content: flex-end; }
.camera-layout { grid-template-columns: 1fr; }
.control-panel { position: static; max-height: none; border-top: 1px solid var(--line); border-left: 0; }
}
@media (max-width: 760px) {
.camera-header, .workspace-heading { align-items: stretch; }
.camera-header { position: static; display: flex; flex-direction: column; }
.header-status, .header-actions, .workspace-tools { justify-content: flex-start; width: 100%; }
.workspace-import { justify-items: start; }
.header-actions .button, .workspace-tools .button { flex: 1 1 calc(50% - 5px); }
.scene-workspace { padding: 18px; }
.workspace-heading { flex-direction: column; }
.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; }
.band-editor__remove { min-height: 34px; }
.metric-grid { grid-template-columns: 1fr 1fr; }
}
+681
View File
@@ -0,0 +1,681 @@
const DEFAULT_BACKGROUND = "#07110f";
const DEFAULT_DYNAMIC_RANGE_DB = 48;
const MAX_PIXEL_RATIO = 3;
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function finiteNumber(value) {
return typeof value === "number" && Number.isFinite(value);
}
function validScan(scan) {
return Boolean(
scan &&
finiteNumber(scan.xMin) &&
finiteNumber(scan.xMax) &&
finiteNumber(scan.yMin) &&
finiteNumber(scan.yMax) &&
scan.xMin < scan.xMax &&
scan.yMin < scan.yMax &&
Number.isInteger(scan.columns) &&
Number.isInteger(scan.rows) &&
scan.columns > 0 &&
scan.rows > 0,
);
}
function parseHexColor(value) {
const match = /^#([0-9a-f]{6})$/i.exec(value ?? "");
if (!match) return [101, 242, 191];
const packed = Number.parseInt(match[1], 16);
return [(packed >> 16) & 0xff, (packed >> 8) & 0xff, packed & 0xff];
}
function resolveCanvasSize(canvas) {
const rect = canvas.getBoundingClientRect?.();
const width = Math.max(1, rect?.width || canvas.clientWidth || 1);
const height = Math.max(1, rect?.height || canvas.clientHeight || 1);
const pixelRatio = clamp(globalThis.devicePixelRatio || 1, 1, MAX_PIXEL_RATIO);
const backingWidth = Math.max(1, Math.round(width * pixelRatio));
const backingHeight = Math.max(1, Math.round(height * pixelRatio));
if (canvas.width !== backingWidth || canvas.height !== backingHeight) {
canvas.width = backingWidth;
canvas.height = backingHeight;
}
return { width, height, pixelRatio };
}
function prepareContext(canvas, context) {
const size = resolveCanvasSize(canvas);
context.setTransform(size.pixelRatio, 0, 0, size.pixelRatio, 0, 0);
context.globalAlpha = 1;
context.globalCompositeOperation = "source-over";
context.imageSmoothingEnabled = true;
context.clearRect(0, 0, size.width, size.height);
return size;
}
function createScheduledCanvas(canvas, draw) {
const context = canvas.getContext("2d", { alpha: false });
if (!context) throw new Error("2D canvas context를 만들 수 없습니다.");
let model = null;
let frameId = 0;
let destroyed = false;
const requestFrame = globalThis.requestAnimationFrame?.bind(globalThis) ??
((callback) => globalThis.setTimeout(callback, 16));
const cancelFrame = globalThis.cancelAnimationFrame?.bind(globalThis) ?? globalThis.clearTimeout?.bind(globalThis);
function drawCurrent() {
frameId = 0;
if (destroyed || model === null) return;
const size = prepareContext(canvas, context);
draw(context, size, model);
}
function schedule() {
if (!destroyed && !frameId) frameId = requestFrame(drawCurrent);
}
function render(nextModel) {
if (destroyed) return;
model = nextModel;
schedule();
}
function renderNow(nextModel) {
if (destroyed) return;
if (arguments.length > 0) model = nextModel;
if (frameId) {
cancelFrame?.(frameId);
frameId = 0;
}
drawCurrent();
}
let resizeObserver = null;
const onWindowResize = () => schedule();
if (typeof globalThis.ResizeObserver === "function") {
resizeObserver = new globalThis.ResizeObserver(schedule);
resizeObserver.observe(canvas);
} else {
globalThis.addEventListener?.("resize", onWindowResize);
}
return {
render,
renderNow,
destroy() {
destroyed = true;
if (frameId) cancelFrame?.(frameId);
frameId = 0;
resizeObserver?.disconnect();
globalThis.removeEventListener?.("resize", onWindowResize);
model = null;
},
};
}
function drawEmpty(context, width, height, message) {
context.fillStyle = DEFAULT_BACKGROUND;
context.fillRect(0, 0, width, height);
context.fillStyle = "rgba(205, 225, 218, .66)";
context.font = "600 13px Inter, system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(message, width / 2, height / 2);
context.textAlign = "left";
context.textBaseline = "alphabetic";
}
function fitPlot(width, height, xSpan, ySpan, margins) {
const availableWidth = Math.max(1, width - margins.left - margins.right);
const availableHeight = Math.max(1, height - margins.top - margins.bottom);
const dataAspect = xSpan / ySpan;
const availableAspect = availableWidth / availableHeight;
let plotWidth = availableWidth;
let plotHeight = availableHeight;
if (dataAspect > availableAspect) plotHeight = plotWidth / dataAspect;
else plotWidth = plotHeight * dataAspect;
return {
x: margins.left + (availableWidth - plotWidth) / 2,
y: margins.top + (availableHeight - plotHeight) / 2,
width: plotWidth,
height: plotHeight,
};
}
export function scanPositionToCanvas(position, scan, plot) {
return {
x: plot.x + ((position[0] - scan.xMin) / (scan.xMax - scan.xMin)) * plot.width,
y: plot.y + ((scan.yMax - position[1]) / (scan.yMax - scan.yMin)) * plot.height,
};
}
function niceStep(span, targetLines = 6) {
const raw = span / targetLines;
const magnitude = 10 ** Math.floor(Math.log10(raw));
const normalized = raw / magnitude;
const multiplier = normalized >= 5 ? 5 : normalized >= 2 ? 2 : 1;
return multiplier * magnitude;
}
function drawScanGrid(context, scan, plot) {
context.save();
context.beginPath();
context.rect(plot.x, plot.y, plot.width, plot.height);
context.clip();
context.fillStyle = "#0b1714";
context.fillRect(plot.x, plot.y, plot.width, plot.height);
const xStep = niceStep(scan.xMax - scan.xMin);
const yStep = niceStep(scan.yMax - scan.yMin);
context.lineWidth = 1;
for (let x = Math.ceil(scan.xMin / xStep) * xStep; x <= scan.xMax + xStep * 1e-6; x += xStep) {
const pixel = scanPositionToCanvas([x, scan.yMin], scan, plot).x;
context.strokeStyle = Math.abs(x) < xStep * 1e-6 ? "rgba(101,242,191,.28)" : "rgba(166,196,186,.11)";
context.beginPath();
context.moveTo(pixel, plot.y);
context.lineTo(pixel, plot.y + plot.height);
context.stroke();
}
for (let y = Math.ceil(scan.yMin / yStep) * yStep; y <= scan.yMax + yStep * 1e-6; y += yStep) {
const pixel = scanPositionToCanvas([scan.xMin, y], scan, plot).y;
context.strokeStyle = Math.abs(y) < yStep * 1e-6 ? "rgba(101,242,191,.28)" : "rgba(166,196,186,.11)";
context.beginPath();
context.moveTo(plot.x, pixel);
context.lineTo(plot.x + plot.width, pixel);
context.stroke();
}
context.restore();
context.strokeStyle = "rgba(174, 211, 199, .32)";
context.lineWidth = 1;
context.strokeRect(plot.x + 0.5, plot.y + 0.5, Math.max(0, plot.width - 1), Math.max(0, plot.height - 1));
context.fillStyle = "rgba(205, 225, 218, .65)";
context.font = "600 11px ui-monospace, SFMono-Regular, Consolas, monospace";
context.textBaseline = "top";
context.textAlign = "left";
context.fillText(`${scan.xMin.toFixed(2)} m`, plot.x, plot.y + plot.height + 8);
context.textAlign = "right";
context.fillText(`${scan.xMax.toFixed(2)} m`, plot.x + plot.width, plot.y + plot.height + 8);
context.save();
context.translate(Math.max(8, plot.x - 12), plot.y + plot.height / 2);
context.rotate(-Math.PI / 2);
context.textAlign = "center";
context.fillText(`y ${scan.yMin.toFixed(2)}…${scan.yMax.toFixed(2)} m`, 0, 0);
context.restore();
context.textAlign = "left";
context.textBaseline = "alphabetic";
}
function drawableImage(image) {
if (!image) return false;
if ("complete" in image && !image.complete) return false;
if ("naturalWidth" in image && image.naturalWidth === 0) return false;
return true;
}
function validPlane(plane) {
return Boolean(
plane &&
finiteNumber(plane.xMin) &&
finiteNumber(plane.xMax) &&
finiteNumber(plane.yMin) &&
finiteNumber(plane.yMax) &&
plane.xMin < plane.xMax &&
plane.yMin < plane.yMax,
);
}
function drawSceneImage(context, image, imagePlane, scan, plot) {
if (!drawableImage(image)) return;
try {
const plane = validPlane(imagePlane) ? imagePlane : scan;
const topLeft = scanPositionToCanvas([plane.xMin, plane.yMax], scan, plot);
const bottomRight = scanPositionToCanvas([plane.xMax, plane.yMin], scan, plot);
context.save();
context.beginPath();
context.rect(plot.x, plot.y, plot.width, plot.height);
context.clip();
context.globalAlpha = 0.72;
context.drawImage(
image,
topLeft.x,
topLeft.y,
bottomRight.x - topLeft.x,
bottomRight.y - topLeft.y,
);
context.fillStyle = "rgba(2, 10, 8, .17)";
context.fillRect(
topLeft.x,
topLeft.y,
bottomRight.x - topLeft.x,
bottomRight.y - topLeft.y,
);
context.restore();
} catch {
// A not-yet-decoded or detached CanvasImageSource is simply omitted.
}
}
function resolveBandMap(band, scan) {
const map = band?.displayMap ?? band?.rawMap;
if (!map) return null;
const values = map.values ?? map;
if (!values || typeof values.length !== "number") return null;
const rows = map.rows ?? scan.rows;
const columns = map.columns ?? scan.columns;
if (rows !== scan.rows || columns !== scan.columns || values.length !== rows * columns) return null;
return {
values,
rows,
columns,
scale: map.scale ?? band.displayScale ?? "power",
};
}
function describeMap(map) {
let peak = -Infinity;
if (map.scale === "db") {
for (let index = 0; index < map.values.length; index += 1) {
const value = Number(map.values[index]);
if (Number.isFinite(value)) peak = Math.max(peak, value);
}
return Number.isFinite(peak) ? { peak, usable: true } : { peak: 0, usable: false };
}
peak = 0;
for (let index = 0; index < map.values.length; index += 1) {
const value = Number(map.values[index]);
if (Number.isFinite(value) && value > peak) peak = value;
}
return { peak, usable: peak > 0 };
}
export function mapValueToDynamicRange(value, scale, peak, dynamicRangeDb) {
if (!Number.isFinite(value) || !Number.isFinite(peak)) return 0;
const range = clamp(Number(dynamicRangeDb) || DEFAULT_DYNAMIC_RANGE_DB, 1, 240);
let relativeDb;
if (scale === "db") relativeDb = value - peak;
else {
if (!(value > 0) || !(peak > 0)) return 0;
relativeDb = 10 * Math.log10(value / peak);
}
return clamp(1 + relativeDb / range, 0, 1);
}
function createHeatmapCanvas(canvas, scan, bands, dynamicRangeDb) {
const ownerDocument = canvas.ownerDocument ?? globalThis.document;
if (!ownerDocument?.createElement) return null;
const heatCanvas = ownerDocument.createElement("canvas");
heatCanvas.width = scan.columns;
heatCanvas.height = scan.rows;
const heatContext = heatCanvas.getContext("2d");
if (!heatContext) return null;
const imageData = heatContext.createImageData(scan.columns, scan.rows);
const resolvedBands = bands
.filter((band) => band && band.visible !== false)
.map((band) => ({ band, map: resolveBandMap(band, scan), color: parseHexColor(band.color) }))
.filter(({ map }) => map)
.map((entry) => ({ ...entry, description: describeMap(entry.map) }))
.filter(({ description }) => description.usable);
for (let index = 0; index < scan.rows * scan.columns; index += 1) {
let alphaSum = 0;
let red = 0;
let green = 0;
let blue = 0;
for (const { band, map, color, description } of resolvedBands) {
const intensity = mapValueToDynamicRange(
Number(map.values[index]),
map.scale,
description.peak,
dynamicRangeDb,
) * clamp(Number(band.opacity ?? 1), 0, 1);
if (!(intensity > 0)) continue;
alphaSum += intensity;
red += color[0] * intensity;
green += color[1] * intensity;
blue += color[2] * intensity;
}
const alpha = Math.min(1, alphaSum);
if (!(alpha > 0)) continue;
const pixelOffset = index * 4;
// ImageData stores unassociated RGB. Dividing the additive premultiplied
// contributions by the final alpha preserves additive color mixing.
imageData.data[pixelOffset] = Math.round(clamp(red / alpha, 0, 255));
imageData.data[pixelOffset + 1] = Math.round(clamp(green / alpha, 0, 255));
imageData.data[pixelOffset + 2] = Math.round(clamp(blue / alpha, 0, 255));
imageData.data[pixelOffset + 3] = Math.round(alpha * 255);
}
heatContext.putImageData(imageData, 0, 0);
return { canvas: heatCanvas, activeBands: resolvedBands.map(({ band }) => band) };
}
function drawTruthMarkers(context, sources, scan, plot) {
const validSources = Array.isArray(sources) ? sources : [];
validSources.forEach((source, index) => {
const position = source?.position;
if (!Array.isArray(position) || !finiteNumber(position[0]) || !finiteNumber(position[1])) return;
const point = scanPositionToCanvas(position, scan, plot);
if (
point.x < plot.x - 16 || point.x > plot.x + plot.width + 16 ||
point.y < plot.y - 16 || point.y > plot.y + plot.height + 16
) return;
context.save();
context.strokeStyle = "rgba(255,255,255,.95)";
context.fillStyle = "rgba(5,14,12,.82)";
context.lineWidth = 2;
context.beginPath();
context.arc(point.x, point.y, 7, 0, Math.PI * 2);
context.fill();
context.stroke();
context.beginPath();
context.moveTo(point.x - 11, point.y);
context.lineTo(point.x + 11, point.y);
context.moveTo(point.x, point.y - 11);
context.lineTo(point.x, point.y + 11);
context.stroke();
const label = source.name || source.id || `S${index + 1}`;
context.font = "700 11px Inter, system-ui, sans-serif";
const labelWidth = context.measureText(label).width + 12;
const labelX = clamp(point.x + 12, plot.x + 4, plot.x + plot.width - labelWidth - 4);
const labelY = clamp(point.y - 20, plot.y + 4, plot.y + plot.height - 21);
context.fillStyle = "rgba(5,14,12,.82)";
context.fillRect(labelX, labelY, labelWidth, 19);
context.fillStyle = "#f4fbf8";
context.fillText(label, labelX + 6, labelY + 13);
context.restore();
});
}
function evaluationForBand(evaluations, bandId) {
if (!evaluations) return null;
if (typeof evaluations.get === "function") return evaluations.get(bandId) ?? null;
if (Array.isArray(evaluations)) {
return evaluations.find((entry) => entry?.bandId === bandId || entry?.id === bandId) ?? null;
}
return evaluations[bandId] ?? null;
}
function peaksForBand(band, evaluations) {
const evaluation = band.evaluation ?? evaluationForBand(evaluations, band.id);
const peaks =
band.peaks ??
evaluation?.peaks ??
evaluation?.detections ??
(evaluation?.globalPeak ? [evaluation.globalPeak] : null) ??
(band.peak ? [band.peak] : []);
return Array.isArray(peaks) ? peaks : [];
}
function drawBandPeakMarkers(context, bands, evaluations, scan, plot) {
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 position = peak?.position;
if (!position || !finiteNumber(position[0]) || !finiteNumber(position[1])) continue;
const point = scanPositionToCanvas(position, scan, plot);
if (
point.x < plot.x || point.x > plot.x + plot.width ||
point.y < plot.y || point.y > plot.y + plot.height
) continue;
context.save();
context.strokeStyle = color;
context.fillStyle = color;
context.lineWidth = 1.75;
context.shadowColor = color;
context.shadowBlur = 5;
context.beginPath();
context.arc(point.x, point.y, 5, 0, Math.PI * 2);
context.stroke();
context.shadowBlur = 0;
context.globalAlpha = 0.9;
context.beginPath();
context.arc(point.x, point.y, 1.4, 0, Math.PI * 2);
context.fill();
context.restore();
}
}
}
function drawBandLegend(context, bands, plot) {
const visibleBands = bands.filter((band) => band && band.visible !== false).slice(0, 8);
if (visibleBands.length === 0) return;
context.save();
context.font = "700 11px Inter, system-ui, sans-serif";
const rows = visibleBands.map((band) => ({
color: band.color ?? "#65f2bf",
label: band.label || band.name || band.id || "band",
}));
const width = Math.min(
plot.width - 12,
Math.max(...rows.map(({ label }) => context.measureText(label).width)) + 34,
);
const height = rows.length * 19 + 10;
const x = plot.x + plot.width - width - 6;
const y = plot.y + 6;
context.fillStyle = "rgba(3, 11, 9, .72)";
context.fillRect(x, y, width, height);
rows.forEach((row, index) => {
const rowY = y + 10 + index * 19;
context.fillStyle = row.color;
context.fillRect(x + 8, rowY + 1, 9, 9);
context.fillStyle = "rgba(239,249,245,.88)";
context.fillText(row.label, x + 23, rowY + 10, Math.max(1, width - 29));
});
context.restore();
}
function normalizeSceneModel(model) {
const value = model ?? {};
return {
scan: value.scan ?? value.experiment?.scan,
bands: value.bands ?? value.result?.bands ?? [],
sources: value.sources ?? value.scene?.sources ?? [],
image: value.image ?? value.imageElement ?? null,
imagePlane: value.imagePlane ?? value.scene?.image?.plane ?? null,
evaluations: value.evaluations ?? null,
dynamicRangeDb:
value.dynamicRangeDb ?? value.experiment?.beamforming?.dynamicRangeDb ?? DEFAULT_DYNAMIC_RANGE_DB,
opacity: value.opacity ?? 0.78,
showTruth: value.showTruth ?? true,
};
}
function drawScene(context, size, sourceModel, canvas) {
const model = normalizeSceneModel(sourceModel);
if (!validScan(model.scan)) {
drawEmpty(context, size.width, size.height, "scan 영역을 불러오면 acoustic map이 표시됩니다.");
return;
}
context.fillStyle = DEFAULT_BACKGROUND;
context.fillRect(0, 0, size.width, size.height);
const scan = model.scan;
const plot = fitPlot(
size.width,
size.height,
scan.xMax - scan.xMin,
scan.yMax - scan.yMin,
{ left: 42, right: 16, top: 16, bottom: 34 },
);
drawScanGrid(context, scan, plot);
drawSceneImage(context, model.image, model.imagePlane, scan, plot);
const bands = Array.isArray(model.bands) ? model.bands : [];
const heatmap = createHeatmapCanvas(canvas, scan, bands, model.dynamicRangeDb);
if (heatmap) {
context.save();
context.beginPath();
context.rect(plot.x, plot.y, plot.width, plot.height);
context.clip();
const requestedOpacity = Number(model.opacity);
context.globalAlpha = finiteNumber(requestedOpacity) ? clamp(requestedOpacity, 0, 1) : 0.78;
context.imageSmoothingEnabled = true;
// 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();
drawBandPeakMarkers(context, bands, model.evaluations, scan, plot);
drawBandLegend(context, heatmap.activeBands, plot);
}
if (model.showTruth) drawTruthMarkers(context, model.sources, scan, plot);
}
/**
* Create the scene-plane renderer.
*
* `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 }`.
*/
export function createSceneVisualization(canvas) {
if (!canvas) throw new TypeError("scene canvas가 필요합니다.");
return createScheduledCanvas(canvas, (context, size, model) => drawScene(context, size, model, canvas));
}
function resolveArrayDefinition(model) {
if (Array.isArray(model?.microphones)) return model;
return model?.arrayDefinition ?? null;
}
function microphoneExtent(microphones, aperture) {
const positions = microphones
.map((microphone) => microphone?.position)
.filter((position) => Array.isArray(position) && finiteNumber(position[0]) && finiteNumber(position[1]));
if (positions.length === 0) return null;
let xMin = Math.min(...positions.map((position) => position[0]));
let xMax = Math.max(...positions.map((position) => position[0]));
let yMin = Math.min(...positions.map((position) => position[1]));
let yMax = Math.max(...positions.map((position) => position[1]));
if (finiteNumber(aperture?.width) && aperture.width > 0) {
xMin = Math.min(xMin, -aperture.width / 2);
xMax = Math.max(xMax, aperture.width / 2);
}
if (finiteNumber(aperture?.height) && aperture.height > 0) {
yMin = Math.min(yMin, -aperture.height / 2);
yMax = Math.max(yMax, aperture.height / 2);
}
const characteristicSize = Math.max(xMax - xMin, yMax - yMin, 0.01);
if (xMin === xMax) {
xMin -= characteristicSize * 0.5;
xMax += characteristicSize * 0.5;
}
if (yMin === yMax) {
yMin -= characteristicSize * 0.5;
yMax += characteristicSize * 0.5;
}
return { xMin, xMax, yMin, yMax, positions };
}
function arrayPositionToCanvas(position, extent, plot) {
return {
x: plot.x + ((position[0] - extent.xMin) / (extent.xMax - extent.xMin)) * plot.width,
y: plot.y + ((extent.yMax - position[1]) / (extent.yMax - extent.yMin)) * plot.height,
};
}
function drawArray(context, size, sourceModel) {
const arrayDefinition = resolveArrayDefinition(sourceModel);
const microphones = Array.isArray(arrayDefinition?.microphones) ? arrayDefinition.microphones : [];
const extent = microphoneExtent(microphones, arrayDefinition?.aperture);
if (!extent) {
drawEmpty(context, size.width, size.height, "array geometry를 불러오면 microphone 배치가 표시됩니다.");
return;
}
context.fillStyle = DEFAULT_BACKGROUND;
context.fillRect(0, 0, size.width, size.height);
const xSpan = extent.xMax - extent.xMin;
const ySpan = extent.yMax - extent.yMin;
const padding = Math.max(xSpan, ySpan) * 0.08;
const view = {
...extent,
xMin: extent.xMin - padding,
xMax: extent.xMax + padding,
yMin: extent.yMin - padding,
yMax: extent.yMax + padding,
};
const plot = fitPlot(
size.width,
size.height,
view.xMax - view.xMin,
view.yMax - view.yMin,
{ left: 24, right: 24, top: 52, bottom: 30 },
);
context.save();
context.beginPath();
context.rect(plot.x, plot.y, plot.width, plot.height);
context.clip();
context.fillStyle = "#0b1714";
context.fillRect(plot.x, plot.y, plot.width, plot.height);
if (view.xMin <= 0 && view.xMax >= 0) {
const x = arrayPositionToCanvas([0, 0], view, plot).x;
context.strokeStyle = "rgba(101,242,191,.16)";
context.beginPath();
context.moveTo(x, plot.y);
context.lineTo(x, plot.y + plot.height);
context.stroke();
}
if (view.yMin <= 0 && view.yMax >= 0) {
const y = arrayPositionToCanvas([0, 0], view, plot).y;
context.strokeStyle = "rgba(101,242,191,.16)";
context.beginPath();
context.moveTo(plot.x, y);
context.lineTo(plot.x + plot.width, y);
context.stroke();
}
const aperture = arrayDefinition.aperture;
if (finiteNumber(aperture?.width) && aperture.width > 0 && finiteNumber(aperture?.height) && aperture.height > 0) {
const topLeft = arrayPositionToCanvas([-aperture.width / 2, aperture.height / 2], view, plot);
const bottomRight = arrayPositionToCanvas([aperture.width / 2, -aperture.height / 2], view, plot);
context.setLineDash([5, 5]);
context.strokeStyle = "rgba(205,225,218,.28)";
context.lineWidth = 1;
context.strokeRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
context.setLineDash([]);
}
const radius = clamp(Math.min(plot.width, plot.height) / Math.max(40, Math.sqrt(microphones.length) * 8), 2, 5);
microphones.forEach((microphone) => {
const position = microphone?.position;
if (!Array.isArray(position) || !finiteNumber(position[0]) || !finiteNumber(position[1])) return;
const point = arrayPositionToCanvas(position, view, plot);
context.fillStyle = "rgba(101,242,191,.92)";
context.beginPath();
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
context.fill();
context.strokeStyle = "rgba(235,255,248,.5)";
context.lineWidth = 0.75;
context.stroke();
});
context.restore();
context.strokeStyle = "rgba(174,211,199,.28)";
context.strokeRect(plot.x + 0.5, plot.y + 0.5, Math.max(0, plot.width - 1), Math.max(0, plot.height - 1));
context.fillStyle = "rgba(239,249,245,.94)";
context.font = "700 13px Inter, system-ui, sans-serif";
context.fillText(arrayDefinition.name || arrayDefinition.id || "Microphone array", 24, 23);
context.fillStyle = "rgba(205,225,218,.66)";
context.font = "600 11px ui-monospace, SFMono-Regular, Consolas, monospace";
const micXMin = Math.min(...extent.positions.map((position) => position[0]));
const micXMax = Math.max(...extent.positions.map((position) => position[0]));
const micYMin = Math.min(...extent.positions.map((position) => position[1]));
const micYMax = Math.max(...extent.positions.map((position) => position[1]));
context.fillText(
`${extent.positions.length} mic · ${(micXMax - micXMin).toFixed(3)} × ${(micYMax - micYMin).toFixed(3)} m`,
24,
41,
);
}
/** Create the microphone-array renderer. Accepts an array definition directly or `{ arrayDefinition }`. */
export function createArrayVisualization(canvas) {
if (!canvas) throw new TypeError("array canvas가 필요합니다.");
return createScheduledCanvas(canvas, drawArray);
}
+63 -1
View File
@@ -1,3 +1,65 @@
# Dataset Builder
음원, ambient audio, 이미지상의 위치를 조합해 scene audio와 ground truth manifest를 만드는 앱입니다.
이미지 평면에 source ground truth를 배치하고 tone, noise, 로컬 audio asset과 ambient를 조합해
재현 가능한 `micarrsim.scene` JSON을 만드는 브라우저 앱입니다.
## 열기
저장소의 `start.cmd`를 실행한 뒤 시작 화면에서 **Dataset Builder**를 선택하거나
<http://127.0.0.1:4173/apps/dataset-builder/>을 엽니다. `index.html`을 직접 열면 module과 Worker가
동작하지 않습니다.
## 기본 사용법
1. **배경 이미지**를 선택하거나 이미지 없이 grid를 사용합니다.
2. **오디오 추가**로 source 또는 ambient에 사용할 파일을 연결합니다.
3. **+ 음원 추가**를 누르고 카드를 선택한 뒤 canvas를 클릭하거나 x/y/z를 입력합니다.
4. source 종류를 설정합니다.
- `Tone`: 주파수와 위상을 갖는 합성 정현파
- `Noise`: seed로 재현되는 white 또는 pink noise
- `Audio asset`: 선택한 파일의 channel을 사용하며 짧은 파일은 반복 가능
5. 필요하면 ambient 종류와 gain을 설정합니다.
6. 검증 카드가 **저장 가능한 장면**이면 **Scene JSON 저장**을 누릅니다.
길이, sample rate, 음속, seed와 mix 정규화도 Scene JSON에 저장됩니다. 이미지가 있으면 표시
평면의 x/y 범위, z와 image pixel 크기가 ground truth 좌표 변환에 사용됩니다.
## 미리 듣기와 Preview WAV
**미리 듣기**는 현재 mix를 브라우저에서 재생합니다. **Preview WAV 저장**은 모든 source와
ambient를 공간 정보 없이 합친 mono PCM16 확인용 파일입니다. 마이크별 전파 지연이 적용된
192채널 녹음이나 acoustic-camera 결과가 아닙니다.
클리핑이 예상되면 저장 전에 경고합니다. 출력이 너무 크면 scene 길이 또는 sample rate를 줄이거나
source gain과 mix 정규화를 조절하세요.
## Scene JSON과 assets
Scene JSON은 오디오·이미지 원본을 포함하지 않고 asset manifest만 기록합니다. 각 descriptor에는
id, 종류, 파일명과 SHA-256이 들어갑니다.
- 파일 한 개는 최대 128 MiB, 연결 파일 합계는 최대 512 MiB입니다.
- decode된 오디오의 합계는 최대 192 MiB입니다.
- Scene을 다시 불러오면 브라우저 권한이 없으므로 원본 이미지와 오디오를 다시 선택해야 합니다.
- 같은 이름의 파일도 SHA-256이 manifest와 다르면 기존 asset으로 연결되지 않습니다.
- 사용 중인 asset은 먼저 source, ambient 또는 image 참조를 바꾼 뒤 제거할 수 있습니다.
원본 파일은 외부로 업로드되지 않고 브라우저 메모리에서만 처리됩니다. 단, JSON에는 파일명이
남을 수 있으므로 공유 전에 확인하세요.
## Acoustic Camera로 넘기기
1. Scene JSON과 사용한 원본 assets를 함께 보관합니다.
2. Acoustic Camera에서 **Scene**으로 JSON을 선택합니다.
3. 사용자 문서가 있다면 **Array**, **Experiment** 순서로 선택합니다.
4. **Assets**에서 원본 이미지와 오디오를 다시 선택해 SHA-256 연결 상태를 확인합니다.
5. 검증이 통과하면 시뮬레이션을 실행합니다.
안전한 전체 import 순서는 **Scene → Array → Experiment → Assets**입니다. 자세한 계산과 Result
설명은 [`../acoustic-camera/README.md`](../acoustic-camera/README.md)를 참고하세요.
## 모델 범위
Builder의 mix는 재현 가능한 source recipe와 위치 ground truth를 만드는 단계입니다. 자유음장 전파,
마이크 calibration과 DAS는 Acoustic Camera에서 수행합니다. 실제 방의 반사·잔향·차폐를 Builder가
추정하지 않습니다.
+102
View File
@@ -0,0 +1,102 @@
import { estimateSceneMix, MAX_MIX_WORKING_BYTES } from "/packages/acoustics-core/mixdown.js";
export function createAudioRuntime() {
let worker = null;
let rejectWorker = null;
let playbackContext = null;
let playbackSource = null;
function cancelMix() {
if (!worker) return false;
worker.terminate();
worker = null;
const reject = rejectWorker;
rejectWorker = null;
reject?.(new DOMException("사용자가 mix 생성을 취소했습니다.", "AbortError"));
return true;
}
function renderMix(scene, assetBuffers) {
if (worker) throw new Error("이미 확인용 mix를 만들고 있습니다.");
const estimate = estimateSceneMix(scene, assetBuffers);
const crossThreadBytes = estimate.workingBytes + estimate.assetBytes;
if (crossThreadBytes > MAX_MIX_WORKING_BYTES) {
throw new Error(
`decode buffer와 Worker 복사본을 포함한 예상 메모리 ${(crossThreadBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
`${(MAX_MIX_WORKING_BYTES / 1024 ** 2).toFixed(0)} MiB 한도를 넘습니다.`,
);
}
return new Promise((resolve, reject) => {
const currentWorker = new Worker("/apps/dataset-builder/mix-worker.js", { type: "module" });
worker = currentWorker;
rejectWorker = reject;
currentWorker.addEventListener("message", ({ data }) => {
if (worker !== currentWorker) return;
worker = null;
rejectWorker = null;
currentWorker.terminate();
if (data.ok) resolve(data.result);
else reject(new Error(data.message));
});
currentWorker.addEventListener("error", (event) => {
if (worker !== currentWorker) return;
worker = null;
rejectWorker = null;
currentWorker.terminate();
reject(new Error(event.message || "mix Worker가 중단되었습니다."));
});
currentWorker.postMessage({ scene, assetBuffers: [...assetBuffers] });
});
}
function stopPlayback() {
if (!playbackSource) return false;
playbackSource.onended = null;
try {
playbackSource.stop();
} catch {
// 이미 끝난 source는 정리만 한다.
}
playbackSource.disconnect();
playbackSource = null;
return true;
}
async function play(result, onEnded) {
stopPlayback();
playbackContext ??= new AudioContext();
await playbackContext.resume();
const buffer = playbackContext.createBuffer(1, result.samples.length, result.sampleRate);
buffer.copyToChannel(result.samples, 0);
playbackSource = playbackContext.createBufferSource();
playbackSource.buffer = buffer;
playbackSource.connect(playbackContext.destination);
playbackSource.onended = () => {
playbackSource?.disconnect();
playbackSource = null;
onEnded?.();
};
playbackSource.start();
}
function dispose() {
cancelMix();
stopPlayback();
playbackContext?.close();
playbackContext = null;
}
return {
cancelMix,
dispose,
get isMixing() {
return Boolean(worker);
},
get isPlaying() {
return Boolean(playbackSource);
},
play,
renderMix,
stopPlayback,
};
}
+135
View File
@@ -0,0 +1,135 @@
import { SOURCE_COLORS } from "./model.js";
function finitePlane(plane) {
return [plane.xMin, plane.xMax, plane.yMin, plane.yMax].every(Number.isFinite) &&
plane.xMin < plane.xMax && plane.yMin < plane.yMax;
}
export function coordinateToPixel(position, plane, width, height) {
return {
x: ((position[0] - plane.xMin) / (plane.xMax - plane.xMin)) * width,
y: ((plane.yMax - position[1]) / (plane.yMax - plane.yMin)) * height,
};
}
export function pixelToCoordinate(point, plane, width, height) {
return {
x: plane.xMin + (point.x / width) * (plane.xMax - plane.xMin),
y: plane.yMax - (point.y / height) * (plane.yMax - plane.yMin),
};
}
function drawGrid(context, width, height, plane) {
context.fillStyle = "#0a1512";
context.fillRect(0, 0, width, height);
context.lineWidth = 1;
for (let index = 0; index <= 10; index += 1) {
const x = (index / 10) * width;
const y = (index / 10) * height;
context.strokeStyle = index === 5 ? "rgba(101,242,191,.17)" : "rgba(145,170,162,.08)";
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, height);
context.stroke();
context.beginPath();
context.moveTo(0, y);
context.lineTo(width, y);
context.stroke();
}
context.fillStyle = "rgba(205,225,218,.52)";
context.font = "600 11px ui-monospace, SFMono-Regular, Consolas, monospace";
context.fillText(`x ${plane.xMin.toFixed(2)} m`, 14, height - 16);
context.textAlign = "right";
context.fillText(`${plane.xMax.toFixed(2)} m`, width - 14, height - 16);
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);
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();
context.shadowColor = color;
context.shadowBlur = selected ? 22 : 12;
context.fillStyle = color;
context.beginPath();
context.arc(point.x, point.y, selected ? 10 : 8, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
if (selected) {
context.strokeStyle = "rgba(255,255,255,.9)";
context.lineWidth = 2;
context.beginPath();
context.arc(point.x, point.y, 16, 0, Math.PI * 2);
context.stroke();
}
const label = `${index + 1} · ${source.name}`;
context.font = "700 12px Inter, system-ui, sans-serif";
const labelWidth = context.measureText(label).width + 18;
const labelX = Math.max(8, Math.min(width - labelWidth - 8, point.x + 18));
const labelY = Math.max(26, Math.min(height - 10, point.y - 12));
context.fillStyle = "rgba(6,15,12,.88)";
context.fillRect(labelX, labelY - 18, labelWidth, 27);
context.fillStyle = "#eef8f4";
context.fillText(label, labelX + 9, labelY);
context.restore();
}
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 };
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, rect.width);
const height = Math.max(1, rect.height);
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
const targetWidth = Math.round(width * pixelRatio);
const targetHeight = Math.round(height * pixelRatio);
if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
canvas.width = targetWidth;
canvas.height = targetHeight;
}
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
if (!finitePlane(plane)) {
context.fillStyle = "#0a1512";
context.fillRect(0, 0, width, height);
context.fillStyle = "#ff7b73";
context.font = "700 14px Inter, system-ui, sans-serif";
context.fillText("표시 평면 범위를 확인하세요.", 20, 30);
return;
}
drawGrid(context, width, height, plane);
if (image) {
context.globalAlpha = 0.7;
context.drawImage(image, 0, 0, width, height);
context.globalAlpha = 1;
context.fillStyle = "rgba(6,15,12,.12)";
context.fillRect(0, 0, width, height);
}
scene.sources.forEach((source, index) => {
drawSource(context, source, 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`;
}
canvas.addEventListener("pointerdown", (event) => {
if (!current || !current.selectedSourceId || !finitePlane(current.plane)) return;
const rect = canvas.getBoundingClientRect();
const xRatio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const yRatio = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
onMove(pixelToCoordinate({ x: xRatio * rect.width, y: yRatio * rect.height }, current.plane, rect.width, rect.height));
});
new ResizeObserver(() => {
if (current) render(current.scene, current.image, current.plane, current.selectedSourceId);
}).observe(canvas);
return { render };
}
+144 -11
View File
@@ -6,27 +6,160 @@
<meta name="description" content="micarrsim 음향 장면 및 ground truth 생성기" />
<title>Dataset Builder · micarrsim</title>
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
<link rel="stylesheet" href="/apps/dataset-builder/styles.css" />
</head>
<body>
<div class="app-shell">
<header class="app-header">
<div class="app-shell builder-shell">
<header class="app-header builder-header">
<a class="app-brand" href="/">
<strong>micarrsim</strong>
<span>Dataset Builder</span>
</a>
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
<div class="header-actions">
<button class="button button--ghost" id="preview-button" type="button">▶ 미리 듣기</button>
<button class="button button--ghost" id="wav-button" type="button" title="공간정보 없이 모든 음원을 합친 mono 확인용 WAV입니다.">Preview WAV 저장</button>
<button class="button button--primary" id="export-button" type="button">Scene JSON 저장</button>
<a class="button button--ghost" href="/apps/acoustic-camera/">Scene 저장 후 Camera 열기 →</a>
</div>
</header>
<main class="app-main">
<section class="empty-state">
<p class="eyebrow">Scene authoring</p>
<h1>음향 장면 만들기</h1>
<p>
이 화면에서 기기 이미지에 음원을 배치하고 ambient audio를 혼합해 scene manifest와
ground truth를 만들게 됩니다. 공통 데이터 계약을 확정한 뒤 편집기를 연결합니다.
</p>
<main class="builder-layout">
<section class="scene-workspace" aria-label="장면 편집 화면">
<div class="workspace-heading">
<div>
<p class="eyebrow">Ground-truth canvas</p>
<h1 id="workspace-title">음향 장면</h1>
</div>
<div class="workspace-tools">
<label class="button button--ghost file-button">
배경 이미지
<input id="image-input" type="file" accept="image/*" />
</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">
Scene 불러오기
<input id="scene-input" type="file" accept="application/json,.json" />
</label>
</div>
</div>
<div class="canvas-card">
<canvas id="scene-canvas" aria-label="이미지 평면과 음원 ground truth"></canvas>
<div class="canvas-empty" id="canvas-empty">
<strong>이미지를 추가하거나 grid를 그대로 사용하세요.</strong>
<span>선택한 음원을 옮기려면 canvas를 클릭하세요.</span>
</div>
<div class="canvas-badge" id="canvas-badge">x −0.60…0.60 m · y −0.45…0.45 m</div>
</div>
<div class="source-strip-heading">
<div>
<h2>음원 ground truth</h2>
<p>카드를 선택한 뒤 canvas를 클릭해 위치를 지정합니다.</p>
</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>
</div>
<div class="asset-list" id="asset-list"></div>
</section>
</section>
<aside class="control-panel" aria-label="장면 파라미터">
<div class="runtime-line">
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
<span class="save-state" id="save-state">기본 장면</span>
</div>
<section class="control-section">
<div class="section-title"><span>01</span><h2>장면</h2></div>
<label class="field field--wide">
<span>이름</span>
<input id="scene-name" type="text" maxlength="120" />
</label>
<label class="field field--wide">
<span>Scene ID</span>
<input id="scene-id" type="text" maxlength="120" pattern="[a-z0-9][a-z0-9._-]*" />
</label>
<div class="field-grid">
<label class="field"><span>길이 (s)</span><input id="scene-duration" type="number" min="0.05" max="300" step="0.05" /></label>
<label class="field"><span>Sample rate</span><input id="sample-rate" type="number" min="8000" max="192000" step="1" /></label>
<label class="field"><span>음속 (m/s)</span><input id="speed-of-sound" type="number" min="250" max="400" step="0.1" /></label>
<label class="field"><span>Seed</span><input id="scene-seed" type="number" min="0" max="4294967295" step="1" /></label>
<label class="field"><span>Mix 정규화</span><select id="mix-normalization"><option value="none">없음</option><option value="peak">Peak</option></select></label>
<label class="field"><span>Target peak</span><input id="target-peak" type="number" min="0.01" max="1" step="0.01" /></label>
</div>
</section>
<section class="control-section">
<div class="section-title"><span>02</span><h2>표시 평면</h2></div>
<div class="field-grid field-grid--compact">
<label class="field"><span>x min</span><input id="plane-x-min" type="number" step="0.05" /></label>
<label class="field"><span>x max</span><input id="plane-x-max" type="number" step="0.05" /></label>
<label class="field"><span>y min</span><input id="plane-y-min" type="number" step="0.05" /></label>
<label class="field"><span>y max</span><input id="plane-y-max" type="number" step="0.05" /></label>
<label class="field"><span>plane z (m)</span><input id="plane-z" type="number" min="0.01" step="0.1" /></label>
</div>
<p class="section-note">x/y는 편집 화면 범위이며, 배경 이미지가 있을 때 image plane으로 JSON에 저장됩니다.</p>
</section>
<section class="control-section" id="source-editor-section">
<div class="section-title"><span>03</span><h2>선택 음원</h2></div>
<div id="no-source-message" class="inline-note">음원을 선택하세요.</div>
<div id="source-fields">
<label class="field field--wide"><span>이름</span><input id="source-name" type="text" maxlength="120" /></label>
<div class="field-grid field-grid--three">
<label class="field"><span>x (m)</span><input id="source-x" type="number" step="0.01" /></label>
<label class="field"><span>y (m)</span><input id="source-y" type="number" step="0.01" /></label>
<label class="field"><span>거리 z (m)</span><input id="source-z" type="number" min="0.01" step="0.1" /></label>
</div>
<div class="field-grid field-grid--three">
<label class="field"><span>Gain (dB)</span><input id="source-gain" type="number" min="-120" max="24" step="1" /></label>
<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>
<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>
<div id="asset-fields">
<label class="field field--wide"><span>Audio asset</span><select id="source-asset"></select></label>
<div class="field-grid">
<label class="field"><span>Channel</span><input id="source-channel" type="number" min="0" max="255" step="1" /></label>
<label class="check-field"><input id="source-loop" type="checkbox" /><span>짧은 파일 반복</span></label>
</div>
</div>
<button class="button button--danger button--full" id="remove-source-button" type="button">선택 음원 삭제</button>
</div>
</section>
<section class="control-section">
<div class="section-title"><span>04</span><h2>Ambient</h2></div>
<label class="field field--wide"><span>종류</span><select id="ambient-type"><option value="none">없음</option><option value="white">White noise</option><option value="pink">Pink noise</option><option value="asset">Audio asset</option></select></label>
<label class="field field--wide" id="ambient-asset-field"><span>Audio asset</span><select id="ambient-asset"></select></label>
<div class="field-grid" id="ambient-asset-options">
<label class="field"><span>Channel</span><input id="ambient-channel" type="number" min="0" max="255" step="1" /></label>
<label class="check-field"><input id="ambient-loop" type="checkbox" /><span>짧은 파일 반복</span></label>
</div>
<label class="field field--wide"><span>Gain (dB)</span><input id="ambient-gain" type="number" min="-120" max="24" step="1" /></label>
</section>
<section class="validation-card" aria-live="polite">
<div class="validation-dot" id="validation-dot"></div>
<div><strong id="validation-title">검증 대기</strong><p id="validation-message">장면을 준비하고 있습니다.</p></div>
</section>
</aside>
</main>
</div>
<script type="module" src="/packages/ui/runtime-status.js"></script>
<script type="module" src="/apps/dataset-builder/main.js"></script>
</body>
</html>
+845
View File
@@ -0,0 +1,845 @@
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
import { decodeAudioFile, MAX_DECODED_AUDIO_BYTES } from "/packages/audio-io/browser-audio.js";
import { downloadBlob, downloadJson, readJsonFile, sha256File } from "/packages/contracts/io.js";
import { validateScene } from "/packages/contracts/validation.js";
import { createAudioRuntime } from "./audio-runtime.js";
import { createSceneCanvas } from "./canvas-view.js";
import {
DEFAULT_VIEW_PLANE,
SOURCE_COLORS,
assetDescriptorForFile,
changeSignalType,
createSceneId,
createSource,
describeSignal,
isAssetReferenced,
matchAssetDescriptor,
prepareSceneForEditing,
referencedAssetIds,
safeDownloadStem,
} from "./model.js";
const byId = (id) => {
const element = document.getElementById(id);
if (!element) throw new Error(`필수 UI를 찾을 수 없습니다: #${id}`);
return element;
};
const elements = Object.fromEntries(
[
"preview-button", "wav-button", "export-button", "image-input", "remove-image-button", "audio-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",
"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",
].map((id) => [id, byId(id)]),
);
elements.validationCard = document.querySelector(".validation-card");
const state = {
scene: null,
selectedSourceId: null,
viewPlane: { ...DEFAULT_VIEW_PLANE },
imageElement: null,
imageUrl: null,
assetFiles: new Map(),
decodedAudio: new Map(),
assetErrors: new Map(),
assetEpoch: 0,
imageJobRevision: 0,
dirty: false,
validationRevision: 0,
mixActionActive: false,
mixActionCancelled: false,
mixAbortController: null,
};
const MAX_LOCAL_ASSET_BYTES = 128 * 1024 * 1024;
const MAX_BOUND_ASSET_BYTES = 512 * 1024 * 1024;
const audioRuntime = createAudioRuntime();
const canvasView = createSceneCanvas(
elements["scene-canvas"],
elements["canvas-empty"],
elements["canvas-badge"],
({ x, y }) => {
const source = selectedSource();
if (!source) return;
source.position[0] = Number(x.toFixed(4));
source.position[1] = Number(y.toFixed(4));
syncSourceEditor();
markChanged("위치 변경됨");
renderSourceCards();
renderCanvas();
},
);
function selectedSource() {
return state.scene?.sources.find((source) => source.id === state.selectedSourceId) ?? null;
}
function numberFrom(element) {
return element.valueAsNumber;
}
function setNumber(element, value) {
element.value = Number.isFinite(value) ? String(value) : "";
}
function setStatus(message) {
elements["save-state"].textContent = message;
}
function cancelPendingMix() {
if (state.mixActionActive) {
state.mixActionCancelled = true;
state.mixAbortController?.abort();
audioRuntime.cancelMix();
}
}
function markChanged(message = "저장하지 않은 변경") {
cancelPendingMix();
if (audioRuntime.stopPlayback()) elements["preview-button"].textContent = "▶ 미리 듣기";
state.dirty = true;
setStatus(message);
scheduleValidation();
}
function currentPlaneZ() {
return state.scene.image?.plane?.z ?? selectedSource()?.position?.[2] ?? 2;
}
function syncImagePlane() {
if (!state.scene.image) return;
Object.assign(state.scene.image.plane, state.viewPlane, { z: numberFrom(elements["plane-z"]) });
}
function renderCanvas() {
if (!state.scene) return;
canvasView.render(state.scene, state.imageElement, state.viewPlane, state.selectedSourceId);
}
function renderSourceCards() {
const list = elements["source-list"];
list.replaceChildren();
state.scene.sources.forEach((source, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "source-card";
button.classList.toggle("is-selected", source.id === state.selectedSourceId);
button.style.setProperty("--source-color", SOURCE_COLORS[index % SOURCE_COLORS.length]);
const heading = document.createElement("span");
heading.className = "source-card__heading";
const dot = document.createElement("span");
dot.className = "source-card__dot";
const name = document.createElement("strong");
name.textContent = source.name;
heading.append(dot, name);
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`;
button.append(heading, signal, position);
button.addEventListener("click", () => {
state.selectedSourceId = source.id;
renderSourceCards();
syncSourceEditor();
renderCanvas();
});
list.append(button);
});
}
function fillAudioSelect(select, selectedId, placeholder) {
select.replaceChildren();
const audioAssets = state.scene.assets.filter((asset) => asset.kind === "audio");
const empty = document.createElement("option");
empty.value = "";
empty.textContent = audioAssets.length > 0 ? placeholder : "먼저 오디오를 추가하세요";
select.append(empty);
for (const asset of audioAssets) {
const option = document.createElement("option");
option.value = asset.id;
option.textContent = asset.fileName;
select.append(option);
}
if (selectedId && !audioAssets.some((asset) => asset.id === selectedId)) {
const missing = document.createElement("option");
missing.value = selectedId;
missing.textContent = `${selectedId} (manifest 누락)`;
select.append(missing);
}
select.value = selectedId ?? "";
}
function syncSourceEditor() {
const source = selectedSource();
elements["no-source-message"].classList.toggle("is-hidden", Boolean(source));
elements["source-fields"].classList.toggle("is-hidden", !source);
if (!source) return;
elements["source-name"].value = source.name;
setNumber(elements["source-x"], source.position[0]);
setNumber(elements["source-y"], source.position[1]);
setNumber(elements["source-z"], source.position[2]);
setNumber(elements["source-gain"], source.gainDb);
setNumber(elements["source-start"], source.startTime);
setNumber(elements["source-duration"], source.duration);
elements["source-type"].value = source.signal.type;
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");
if (source.signal.type === "tone") setNumber(elements["tone-frequency"], source.signal.frequency);
if (source.signal.type === "noise") elements["noise-color"].value = source.signal.color;
if (source.signal.type === "asset") {
fillAudioSelect(elements["source-asset"], source.signal.assetId, "Audio asset 선택");
setNumber(elements["source-channel"], source.signal.channel ?? 0);
elements["source-loop"].checked = source.signal.loop ?? false;
}
}
function ambientEntry() {
return state.scene.ambient[0] ?? null;
}
function syncAmbientEditor() {
const ambient = ambientEntry();
const type = ambient?.signal?.type === "noise" ? ambient.signal.color : ambient?.signal?.type ?? "none";
elements["ambient-type"].value = type;
elements["ambient-asset-field"].classList.toggle("is-hidden", type !== "asset");
elements["ambient-asset-options"].classList.toggle("is-hidden", type !== "asset");
fillAudioSelect(elements["ambient-asset"], type === "asset" ? ambient.signal.assetId : "", "Audio asset 선택");
setNumber(elements["ambient-channel"], type === "asset" ? ambient.signal.channel ?? 0 : 0);
elements["ambient-loop"].checked = type === "asset" ? ambient.signal.loop ?? false : false;
setNumber(elements["ambient-gain"], ambient?.gainDb ?? -36);
}
function renderAssetList() {
const list = elements["asset-list"];
list.replaceChildren();
if (state.scene.assets.length === 0) {
const empty = document.createElement("span");
empty.className = "inline-note";
empty.textContent = "아직 asset이 없습니다.";
list.append(empty);
return;
}
for (const asset of state.scene.assets) {
const chip = document.createElement("div");
chip.className = "asset-chip";
const label = document.createElement("span");
label.className = "asset-chip__label";
label.textContent = `${asset.kind} · ${asset.fileName}`;
label.title = `${asset.id}${asset.sha256 ? ` · ${asset.sha256}` : ""}`;
const availability = document.createElement("span");
availability.className = "asset-chip__state";
availability.textContent = state.assetErrors.has(asset.id)
? "오류"
: state.assetFiles.has(asset.id) ? "연결됨" : "파일 필요";
if (state.assetErrors.has(asset.id)) availability.title = state.assetErrors.get(asset.id);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "asset-chip__remove";
remove.textContent = "×";
const referenced = isAssetReferenced(state.scene, asset.id);
remove.disabled = referenced;
remove.title = referenced ? "먼저 이 asset을 사용하는 image/source/ambient를 변경하세요." : `${asset.fileName} 제거`;
remove.addEventListener("click", () => removeAsset(asset.id));
chip.append(label, availability, remove);
list.append(chip);
}
}
function syncSceneControls() {
const scene = state.scene;
elements["scene-name"].value = scene.name;
elements["scene-id"].value = scene.id;
setNumber(elements["scene-duration"], scene.duration);
elements["sample-rate"].value = String(scene.sampleRate);
setNumber(elements["speed-of-sound"], scene.speedOfSound);
setNumber(elements["scene-seed"], scene.seed);
elements["mix-normalization"].value = scene.mix.normalization;
setNumber(elements["target-peak"], scene.mix.targetPeak);
setNumber(elements["plane-x-min"], state.viewPlane.xMin);
setNumber(elements["plane-x-max"], state.viewPlane.xMax);
setNumber(elements["plane-y-min"], state.viewPlane.yMin);
setNumber(elements["plane-y-max"], state.viewPlane.yMax);
setNumber(elements["plane-z"], currentPlaneZ());
elements["plane-z"].disabled = !scene.image;
elements["remove-image-button"].classList.toggle("is-hidden", !scene.image);
elements["target-peak"].disabled = scene.mix.normalization !== "peak";
elements["workspace-title"].textContent = scene.name;
}
function renderAll() {
syncSceneControls();
renderSourceCards();
syncSourceEditor();
syncAmbientEditor();
renderAssetList();
renderCanvas();
scheduleValidation();
}
function requiredAssetIds({ audioOnly = false } = {}) {
const referenced = referencedAssetIds(state.scene);
if (!audioOnly) return [...referenced];
const assetsById = new Map(state.scene.assets.map((asset) => [asset.id, asset]));
return [...referenced].filter((id) => assetsById.get(id)?.kind === "audio");
}
function unavailableAssetIds(options) {
return requiredAssetIds(options).filter(
(id) => !state.assetFiles.has(id) || state.assetErrors.has(id),
);
}
function scheduleValidation() {
const revision = ++state.validationRevision;
window.clearTimeout(state.validationTimer);
document.documentElement.dataset.sceneValid = "pending";
document.documentElement.dataset.assetsReady = "pending";
if (!state.mixActionActive) {
elements["preview-button"].disabled = true;
elements["wav-button"].disabled = true;
elements["export-button"].disabled = true;
}
state.validationTimer = window.setTimeout(async () => {
const issues = await validateScene(state.scene);
if (revision !== state.validationRevision) return;
const missingAll = unavailableAssetIds();
const missingAudio = unavailableAssetIds({ audioOnly: true });
const valid = issues.length === 0;
document.documentElement.dataset.sceneValid = String(valid);
document.documentElement.dataset.assetsReady = String(missingAll.length === 0);
elements.validationCard.classList.toggle("is-valid", valid && missingAll.length === 0);
elements.validationCard.classList.toggle("is-invalid", !valid || missingAll.length > 0);
if (!valid) {
elements["validation-title"].textContent = `${issues.length}개 항목을 확인하세요`;
elements["validation-message"].textContent = issues
.slice(0, 3)
.map(({ path, message }) => `${path} ${message}`)
.join(" · ");
} else if (missingAll.length > 0) {
elements["validation-title"].textContent = `원본 asset ${missingAll.length}개 필요`;
elements["validation-message"].textContent = missingAll.join(", ");
} else {
elements["validation-title"].textContent = "저장 가능한 장면";
elements["validation-message"].textContent = `${state.scene.sources.length}개 음원 · ${state.scene.sampleRate.toLocaleString()} Hz · ${state.scene.duration} s`;
}
if (!state.mixActionActive) {
elements["preview-button"].disabled = !valid || missingAudio.length > 0;
elements["wav-button"].disabled = !valid || missingAudio.length > 0;
elements["export-button"].disabled = !valid || missingAll.length > 0;
}
}, 100);
}
function removeAsset(assetId) {
const asset = state.scene.assets.find((candidate) => candidate.id === assetId);
if (!asset) return;
if (isAssetReferenced(state.scene, assetId)) {
setStatus(`${asset.fileName}은 장면에서 사용 중입니다.`);
return;
}
state.assetEpoch += 1;
state.scene.assets = state.scene.assets.filter((candidate) => candidate.id !== assetId);
state.assetFiles.delete(assetId);
state.decodedAudio.delete(assetId);
state.assetErrors.delete(assetId);
renderAssetList();
syncSourceEditor();
syncAmbientEditor();
markChanged("asset 제거됨");
}
function findDescriptorForFile(file, kind, hash) {
return matchAssetDescriptor(state.scene.assets, new Set(state.assetFiles.keys()), file.name, kind, hash);
}
async function decodeImageFile(file) {
const url = URL.createObjectURL(file);
try {
const image = new Image();
image.decoding = "async";
image.src = url;
await image.decode();
if (image.naturalWidth > 32768 || image.naturalHeight > 32768) {
throw new Error("이미지는 가로·세로 32,768 px 이하여야 합니다.");
}
return { image, url };
} catch (error) {
URL.revokeObjectURL(url);
throw error;
}
}
async function attachFile(file, kind, expectedEpoch = state.assetEpoch) {
const epoch = expectedEpoch;
if (file.size > MAX_LOCAL_ASSET_BYTES) {
throw new Error(`${file.name}은 ${(MAX_LOCAL_ASSET_BYTES / 1024 ** 2).toFixed(0)} MiB 이하여야 합니다.`);
}
setStatus(`${file.name} SHA-256 계산 중…`);
const hash = await sha256File(file);
if (epoch !== state.assetEpoch) return null;
const match = findDescriptorForFile(file, kind, hash);
let descriptor = match.descriptor;
if (!descriptor) {
descriptor = assetDescriptorForFile(file, kind, new Set(state.scene.assets.map((asset) => asset.id)));
state.scene.assets.push(descriptor);
}
const currentFileBytes = state.assetFiles.get(descriptor.id)?.size ?? 0;
const boundBytes = [...state.assetFiles.values()].reduce((sum, candidate) => sum + candidate.size, 0);
if (boundBytes - currentFileBytes + file.size > MAX_BOUND_ASSET_BYTES) {
if (!match.descriptor) state.scene.assets = state.scene.assets.filter((asset) => asset !== descriptor);
throw new Error(`연결한 asset 파일 합계는 ${(MAX_BOUND_ASSET_BYTES / 1024 ** 2).toFixed(0)} MiB 이하여야 합니다.`);
}
descriptor.sha256 = hash;
if (file.type) descriptor.mimeType = file.type;
state.assetFiles.set(descriptor.id, file);
state.decodedAudio.delete(descriptor.id);
state.assetErrors.delete(descriptor.id);
if (match.conflict) {
setStatus(`같은 이름의 다른 파일을 ${descriptor.id}로 분리했습니다.`);
} else {
setStatus(`${file.name} 연결됨 · SHA ${hash.slice(0, 10)}…`);
}
return descriptor;
}
function decodedAudioBytes(decoded) {
return decoded.channels.reduce((sum, channel) => sum + channel.byteLength, 0);
}
function abortIfMixChanged(expectedEpoch) {
if (state.mixActionCancelled || expectedEpoch !== state.assetEpoch) {
throw new DOMException("장면 변경으로 mix 생성을 취소했습니다.", "AbortError");
}
}
async function ensureDecodedAudio(expectedEpoch, signal) {
const buffers = new Map();
const requiredIds = new Set(requiredAssetIds({ audioOnly: true }));
for (const assetId of state.decodedAudio.keys()) {
if (!requiredIds.has(assetId)) state.decodedAudio.delete(assetId);
}
let retainedBytes = 0;
for (const [assetId, decoded] of state.decodedAudio) {
if (requiredIds.has(assetId) && decoded.sampleRate === state.scene.sampleRate) retainedBytes += decodedAudioBytes(decoded);
}
for (const assetId of requiredIds) {
abortIfMixChanged(expectedEpoch);
const file = state.assetFiles.get(assetId);
const asset = state.scene.assets.find((candidate) => candidate.id === assetId);
if (!file || !asset) throw new Error(`오디오 원본 파일이 필요합니다: ${assetId}`);
let decoded = state.decodedAudio.get(assetId);
if (!decoded || decoded.sampleRate !== state.scene.sampleRate) {
setStatus(`${asset.fileName} ${state.scene.sampleRate.toLocaleString()} Hz decode 중…`);
try {
const remainingBytes = MAX_DECODED_AUDIO_BYTES - retainedBytes;
if (remainingBytes <= 0) throw new Error("참조 오디오의 누적 decode 메모리 한도를 넘습니다.");
decoded = await decodeAudioFile(file, state.scene.sampleRate, remainingBytes, signal);
abortIfMixChanged(expectedEpoch);
state.decodedAudio.set(assetId, decoded);
retainedBytes += decodedAudioBytes(decoded);
state.assetErrors.delete(assetId);
} catch (error) {
if (expectedEpoch === state.assetEpoch && !state.mixActionCancelled) {
state.assetErrors.set(assetId, error instanceof Error ? error.message : String(error));
renderAssetList();
}
throw error;
}
}
buffers.set(assetId, decoded);
}
return buffers;
}
function formatMixStats(result) {
const values = [
`입력 peak ${result.preNormalizationPeak.toFixed(4)}`,
`scale ${result.appliedScale.toFixed(4)}`,
`출력 peak ${result.peak.toFixed(4)}`,
];
if (result.clippedSampleCount > 0) values.push(`clipping ${result.clippedSampleCount.toLocaleString()} samples`);
return values.join(" · ");
}
async function runMixAction(button, busyLabel, onResult) {
if (state.mixActionActive) {
cancelPendingMix();
setStatus("확인용 mix 생성을 취소했습니다.");
return;
}
state.mixActionActive = true;
state.mixActionCancelled = false;
state.mixAbortController = new AbortController();
const buttons = [elements["preview-button"], elements["wav-button"], elements["export-button"]];
const expectedEpoch = state.assetEpoch;
const originalText = button.textContent;
buttons.forEach((candidate) => { candidate.disabled = candidate !== button; });
button.textContent = "취소";
try {
setStatus(busyLabel);
const assetBuffers = await ensureDecodedAudio(expectedEpoch, state.mixAbortController.signal);
abortIfMixChanged(expectedEpoch);
const result = await audioRuntime.renderMix(structuredClone(state.scene), assetBuffers);
await onResult(result);
setStatus(formatMixStats(result));
} catch (error) {
if (error?.name !== "AbortError") {
setStatus(error instanceof Error ? error.message : String(error));
}
} finally {
state.mixActionActive = false;
state.mixAbortController = null;
button.textContent = originalText;
scheduleValidation();
}
}
function bindInput(id, callback, eventName = "input") {
elements[id].addEventListener(eventName, () => {
callback(elements[id]);
markChanged();
});
}
function bindSceneControls() {
bindInput("scene-name", (input) => {
state.scene.name = input.value;
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("sample-rate", (input) => {
state.scene.sampleRate = Number(input.value);
state.decodedAudio.clear();
for (const asset of state.scene.assets) {
if (asset.kind === "audio") state.assetErrors.delete(asset.id);
}
renderAssetList();
}, "change");
bindInput("speed-of-sound", (input) => { state.scene.speedOfSound = numberFrom(input); });
bindInput("scene-seed", (input) => { state.scene.seed = numberFrom(input); });
bindInput("mix-normalization", (input) => {
state.scene.mix.normalization = input.value;
elements["target-peak"].disabled = input.value !== "peak";
}, "change");
bindInput("target-peak", (input) => { state.scene.mix.targetPeak = numberFrom(input); });
for (const [id, key] of [
["plane-x-min", "xMin"], ["plane-x-max", "xMax"],
["plane-y-min", "yMin"], ["plane-y-max", "yMax"],
]) {
bindInput(id, (input) => {
state.viewPlane[key] = numberFrom(input);
syncImagePlane();
renderCanvas();
});
}
bindInput("plane-z", () => { syncImagePlane(); });
}
function bindSourceControls() {
elements["add-source-button"].addEventListener("click", () => {
if (state.scene.sources.length >= 32) {
setStatus("음원은 최대 32개까지 추가할 수 있습니다.");
return;
}
const source = createSource(state.scene);
state.scene.sources.push(source);
state.selectedSourceId = source.id;
renderSourceCards();
syncSourceEditor();
renderCanvas();
markChanged("음원 추가됨");
});
elements["remove-source-button"].addEventListener("click", () => {
if (state.scene.sources.length <= 1) {
setStatus("장면에는 음원이 하나 이상 필요합니다.");
return;
}
state.scene.sources = state.scene.sources.filter((source) => source.id !== state.selectedSourceId);
state.selectedSourceId = state.scene.sources[0].id;
renderSourceCards();
syncSourceEditor();
renderCanvas();
markChanged("음원 삭제됨");
});
bindInput("source-name", (input) => { selectedSource().name = input.value; renderSourceCards(); });
for (const [id, index] of [["source-x", 0], ["source-y", 1], ["source-z", 2]]) {
bindInput(id, (input) => {
selectedSource().position[index] = numberFrom(input);
renderSourceCards();
renderCanvas();
});
}
bindInput("source-gain", (input) => { selectedSource().gainDb = numberFrom(input); });
bindInput("source-start", (input) => { selectedSource().startTime = numberFrom(input); });
bindInput("source-duration", (input) => { selectedSource().duration = numberFrom(input); });
bindInput("source-type", (input) => {
const firstAudioId = state.scene.assets.find((asset) => asset.kind === "audio")?.id ?? "";
changeSignalType(selectedSource(), input.value, firstAudioId);
syncSourceEditor();
renderSourceCards();
}, "change");
bindInput("tone-frequency", (input) => { selectedSource().signal.frequency = numberFrom(input); renderSourceCards(); });
bindInput("noise-color", (input) => { selectedSource().signal.color = input.value; renderSourceCards(); }, "change");
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");
}
function createAmbientSignal(type) {
if (type === "white" || type === "pink") return { type: "noise", color: type, seedOffset: 0 };
const assetId = state.scene.assets.find((asset) => asset.kind === "audio")?.id ?? "";
return { type: "asset", assetId, channel: 0, loop: true };
}
function bindAmbientControls() {
bindInput("ambient-type", (input) => {
if (input.value === "none") {
state.scene.ambient = [];
} else {
const current = ambientEntry();
const rest = state.scene.ambient.slice(1);
const first = {
id: current?.id ?? "ambient-1",
gainDb: current?.gainDb ?? -36,
signal: createAmbientSignal(input.value),
};
state.scene.ambient = [first, ...rest];
}
syncAmbientEditor();
}, "change");
bindInput("ambient-gain", (input) => {
if (ambientEntry()) ambientEntry().gainDb = numberFrom(input);
});
bindInput("ambient-asset", (input) => {
if (ambientEntry()?.signal.type === "asset") ambientEntry().signal.assetId = input.value;
}, "change");
bindInput("ambient-channel", (input) => {
if (ambientEntry()?.signal.type === "asset") ambientEntry().signal.channel = numberFrom(input);
});
bindInput("ambient-loop", (input) => {
if (ambientEntry()?.signal.type === "asset") ambientEntry().signal.loop = input.checked;
}, "change");
}
async function handleImageFile(file, imageJobRevision, epoch) {
const descriptor = await attachFile(file, "image", epoch);
if (!descriptor || imageJobRevision !== state.imageJobRevision) return;
try {
const loaded = await decodeImageFile(file);
if (epoch !== state.assetEpoch || imageJobRevision !== state.imageJobRevision) {
URL.revokeObjectURL(loaded.url);
return;
}
const { image } = loaded;
if (state.imageUrl) URL.revokeObjectURL(state.imageUrl);
state.imageUrl = loaded.url;
state.imageElement = image;
const existingPlane = state.scene.image?.assetId === descriptor.id ? state.scene.image.plane : null;
const plane = existingPlane
? { ...existingPlane }
: { ...state.viewPlane, z: currentPlaneZ() };
state.scene.image = {
assetId: descriptor.id,
widthPixels: image.naturalWidth,
heightPixels: image.naturalHeight,
plane,
};
state.viewPlane = {
xMin: plane.xMin, xMax: plane.xMax, yMin: plane.yMin, yMax: plane.yMax,
};
state.assetErrors.delete(descriptor.id);
renderAll();
markChanged("배경 이미지 연결됨");
} catch (error) {
if (epoch === state.assetEpoch && imageJobRevision === state.imageJobRevision) {
state.assetErrors.set(descriptor.id, error instanceof Error ? error.message : String(error));
renderAssetList();
scheduleValidation();
}
throw error;
}
}
function bindFileControls() {
elements["image-input"].addEventListener("change", async (event) => {
const [file] = event.target.files;
event.target.value = "";
if (!file) return;
cancelPendingMix();
const imageJobRevision = ++state.imageJobRevision;
const epoch = ++state.assetEpoch;
try {
await handleImageFile(file, imageJobRevision, epoch);
} catch (error) {
if (imageJobRevision === state.imageJobRevision) {
setStatus(error instanceof Error ? error.message : String(error));
}
}
});
elements["remove-image-button"].addEventListener("click", () => {
if (!state.scene.image) return;
cancelPendingMix();
state.imageJobRevision += 1;
state.assetEpoch += 1;
if (state.imageUrl) URL.revokeObjectURL(state.imageUrl);
state.imageUrl = null;
state.imageElement = null;
delete state.scene.image;
renderAll();
markChanged("배경 이미지 해제됨");
});
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}개 오디오 처리 완료`);
}
});
elements["scene-input"].addEventListener("change", async (event) => {
const [file] = event.target.files;
event.target.value = "";
if (!file) return;
cancelPendingMix();
if (audioRuntime.stopPlayback()) elements["preview-button"].textContent = "▶ 미리 듣기";
try {
const candidate = await readJsonFile(file);
const issues = await validateScene(candidate);
if (issues.length > 0) {
throw new Error(issues.slice(0, 4).map(({ path, message }) => `${path} ${message}`).join(" · "));
}
state.assetEpoch += 1;
state.imageJobRevision += 1;
if (state.imageUrl) URL.revokeObjectURL(state.imageUrl);
state.imageUrl = null;
state.imageElement = null;
state.assetFiles.clear();
state.decodedAudio.clear();
state.assetErrors.clear();
state.scene = prepareSceneForEditing(candidate);
state.selectedSourceId = state.scene.sources[0].id;
state.viewPlane = state.scene.image
? {
xMin: state.scene.image.plane.xMin, xMax: state.scene.image.plane.xMax,
yMin: state.scene.image.plane.yMin, yMax: state.scene.image.plane.yMax,
}
: { ...DEFAULT_VIEW_PLANE };
state.dirty = false;
renderAll();
setStatus(`Scene 불러옴 · ${requiredAssetIds().length}개 원본 asset을 다시 선택하세요.`);
} catch (error) {
setStatus(`불러오기 실패 · ${error instanceof Error ? error.message : String(error)}`);
}
});
}
function bindOutputControls() {
elements["preview-button"].addEventListener("click", async () => {
if (audioRuntime.stopPlayback()) {
elements["preview-button"].textContent = "▶ 미리 듣기";
setStatus("미리 듣기를 중지했습니다.");
return;
}
await runMixAction(elements["preview-button"], "확인용 mono mix 생성 중…", async (result) => {
const stats = formatMixStats(result);
await audioRuntime.play(result, () => {
elements["preview-button"].textContent = "▶ 미리 듣기";
setStatus(`미리 듣기 완료 · ${stats}`);
});
});
if (audioRuntime.isPlaying) elements["preview-button"].textContent = "■ 중지";
});
elements["wav-button"].addEventListener("click", async () => {
await runMixAction(elements["wav-button"], "확인용 WAV 생성 중…", async (result) => {
if (result.clippedSampleCount > 0) {
const shouldSave = window.confirm(
`${result.clippedSampleCount.toLocaleString()}개 sample이 PCM16 범위에서 잘립니다. 그래도 저장할까요?`,
);
if (!shouldSave) return;
}
const blob = encodeMonoPcm16Wav(result.samples, result.sampleRate);
downloadBlob(blob, `${safeDownloadStem(state.scene)}.preview-mix.wav`);
});
});
elements["export-button"].addEventListener("click", async () => {
try {
const issues = await validateScene(state.scene);
if (issues.length > 0) throw new Error(`${issues[0].path} ${issues[0].message}`);
const unavailable = unavailableAssetIds();
if (unavailable.length > 0) throw new Error(`원본 asset을 연결하세요: ${unavailable.join(", ")}`);
downloadJson(state.scene, `${safeDownloadStem(state.scene)}.scene.json`);
state.dirty = false;
setStatus("Scene JSON 저장됨");
} catch (error) {
setStatus(`저장 실패 · ${error instanceof Error ? error.message : String(error)}`);
}
});
}
async function initialize() {
elements["preview-button"].disabled = true;
elements["wav-button"].disabled = true;
elements["export-button"].disabled = true;
const response = await fetch("/assets/sample-scenes/single-tone.scene.json", { cache: "no-store" });
if (!response.ok) throw new Error(`기본 장면을 불러오지 못했습니다: HTTP ${response.status}`);
state.scene = prepareSceneForEditing(await response.json());
state.scene.id = createSceneId();
state.selectedSourceId = state.scene.sources[0].id;
if (state.scene.image) {
state.viewPlane = {
xMin: state.scene.image.plane.xMin, xMax: state.scene.image.plane.xMax,
yMin: state.scene.image.plane.yMin, yMax: state.scene.image.plane.yMax,
};
}
bindSceneControls();
bindSourceControls();
bindAmbientControls();
bindFileControls();
bindOutputControls();
renderAll();
setStatus("기본 장면 준비됨");
document.documentElement.dataset.appReady = "true";
}
window.addEventListener("beforeunload", (event) => {
if (!state.dirty) return;
event.preventDefault();
event.returnValue = "";
});
window.addEventListener("unload", () => {
if (state.imageUrl) URL.revokeObjectURL(state.imageUrl);
audioRuntime.dispose();
});
initialize().catch((error) => {
document.documentElement.dataset.appReady = "false";
elements["validation-title"].textContent = "초기화 실패";
elements["validation-message"].textContent = error instanceof Error ? error.message : String(error);
elements.validationCard.classList.add("is-invalid");
setStatus("초기화 실패");
});
+14
View File
@@ -0,0 +1,14 @@
import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
self.addEventListener("message", ({ data }) => {
try {
const assetBuffers = new Map(data.assetBuffers);
const result = renderSceneMix(data.scene, assetBuffers);
self.postMessage({ ok: true, result }, [result.samples.buffer]);
} catch (error) {
self.postMessage({
ok: false,
message: error instanceof Error ? error.message : String(error),
});
}
});
+105
View File
@@ -0,0 +1,105 @@
export const DEFAULT_VIEW_PLANE = Object.freeze({
xMin: -0.6,
xMax: 0.6,
yMin: -0.45,
yMax: 0.45,
});
export const SOURCE_COLORS = ["#65f2bf", "#58c7e8", "#ffc56e", "#ff8f87", "#be9bff", "#8ee36b"];
export function prepareSceneForEditing(scene) {
const prepared = structuredClone(scene);
prepared.assets ??= [];
prepared.ambient ??= [];
prepared.mix ??= { normalization: "none", targetPeak: 0.95 };
return prepared;
}
export function createSceneId(now = Date.now(), randomValue = crypto.getRandomValues(new Uint32Array(1))[0]) {
return `scene-${now.toString(36)}-${randomValue.toString(36)}`;
}
export function uniqueId(base, existingIds) {
const normalized = String(base)
.toLowerCase()
.normalize("NFKD")
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || "asset";
let candidate = normalized;
let suffix = 2;
while (existingIds.has(candidate)) {
candidate = `${normalized}-${suffix}`;
suffix += 1;
}
return candidate;
}
export function assetDescriptorForFile(file, kind, existingIds) {
const baseName = file.name.replace(/\.[^.]+$/, "") || kind;
return {
id: uniqueId(`${kind}-${baseName}`, existingIds),
kind,
fileName: file.name,
mimeType: file.type || (kind === "image" ? "image/*" : "audio/*"),
};
}
export function matchAssetDescriptor(assets, boundAssetIds, fileName, kind, hash) {
const candidates = assets.filter((asset) => asset.kind === kind);
const hashMatches = candidates.filter((asset) => asset.sha256 === hash);
const hashMatch = hashMatches.find((asset) => !boundAssetIds.has(asset.id)) ?? hashMatches[0];
if (hashMatch) return { descriptor: hashMatch, conflict: false };
const baseName = (path) => path.split(/[\\/]/).pop();
const nameCandidates = candidates.filter((asset) => baseName(asset.fileName) === fileName);
const compatible = nameCandidates.find((asset) => !asset.sha256 && !boundAssetIds.has(asset.id));
if (compatible) return { descriptor: compatible, conflict: false };
return { descriptor: null, conflict: nameCandidates.some((asset) => asset.sha256 !== hash) };
}
export function createSource(scene) {
const existingIds = new Set(scene.sources.map((source) => source.id));
const ordinal = scene.sources.length + 1;
return {
id: uniqueId(`source-${ordinal}`, existingIds),
name: `음원 ${ordinal}`,
position: [0, 0, scene.image?.plane?.z ?? scene.sources[0]?.position?.[2] ?? 2],
gainDb: -6,
startTime: 0,
duration: scene.duration,
signal: { type: "tone", frequency: Math.min(1000, scene.sampleRate / 4), phaseDeg: 0 },
};
}
export function changeSignalType(source, type, firstAudioAssetId = "") {
if (type === "tone") {
source.signal = { type: "tone", frequency: 1000, phaseDeg: 0 };
} else if (type === "noise") {
source.signal = { type: "noise", color: "white", seedOffset: 0 };
} else if (type === "asset") {
source.signal = { type: "asset", assetId: firstAudioAssetId, channel: 0, loop: false };
}
}
export function describeSignal(signal) {
if (signal.type === "tone") return `${signal.frequency} Hz tone`;
if (signal.type === "noise") return `${signal.color} noise`;
if (signal.type === "asset") return `asset · ${signal.assetId || "미선택"}`;
return "알 수 없는 신호";
}
export function referencedAssetIds(scene) {
const ids = new Set();
if (scene.image?.assetId) ids.add(scene.image.assetId);
for (const entry of [...scene.sources, ...(scene.ambient ?? [])]) {
if (entry.signal?.type === "asset" && entry.signal.assetId) ids.add(entry.signal.assetId);
}
return ids;
}
export function isAssetReferenced(scene, assetId) {
return referencedAssetIds(scene).has(assetId);
}
export function safeDownloadStem(scene) {
return uniqueId(scene.id || scene.name || "scene", new Set());
}
+552
View File
@@ -0,0 +1,552 @@
.builder-shell {
min-width: 320px;
}
.builder-header {
position: sticky;
z-index: 20;
top: 0;
}
.header-actions,
.workspace-tools,
.runtime-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
min-width: 0;
max-width: 100%;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 9px 14px;
border: 1px solid var(--line);
border-radius: 11px;
color: var(--text);
background: var(--surface-raised);
cursor: pointer;
font-size: 0.82rem;
font-weight: 720;
text-decoration: none;
transition: border-color 140ms ease, background 140ms ease, transform 140ms ease;
}
.button:hover,
.button:focus-visible {
border-color: var(--mint);
outline: none;
transform: translateY(-1px);
}
.button:disabled {
cursor: wait;
opacity: 0.55;
transform: none;
}
.button--primary {
border-color: rgb(101 242 191 / 55%);
color: #06130f;
background: var(--mint);
}
.button--ghost {
background: rgb(255 255 255 / 3%);
}
.button--danger {
border-color: rgb(255 123 115 / 35%);
color: var(--danger);
background: rgb(255 123 115 / 7%);
}
.button--full {
width: 100%;
}
.file-button input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
}
.builder-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(330px, 400px);
min-height: calc(100vh - 65px);
}
.scene-workspace {
min-width: 0;
padding: clamp(22px, 3vw, 42px);
}
.workspace-heading,
.source-strip-heading,
.asset-shelf {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
}
.workspace-heading {
margin-bottom: 22px;
}
.workspace-heading h1 {
margin-bottom: 0;
font-size: clamp(1.8rem, 4vw, 3.2rem);
letter-spacing: -0.055em;
}
.workspace-heading .eyebrow {
margin-bottom: 7px;
}
.canvas-card {
position: relative;
overflow: hidden;
min-height: 520px;
border: 1px solid var(--line);
border-radius: 22px;
background: #0a1512;
box-shadow: var(--shadow);
}
#scene-canvas {
display: block;
width: 100%;
height: clamp(520px, 61vh, 720px);
cursor: crosshair;
touch-action: none;
}
.canvas-empty {
position: absolute;
top: 34px;
left: 50%;
display: grid;
width: min(430px, calc(100% - 48px));
gap: 7px;
color: var(--muted);
text-align: center;
pointer-events: none;
transform: translateX(-50%);
}
.canvas-empty strong {
color: #cfe2db;
font-size: 0.95rem;
}
.canvas-empty strong,
.canvas-empty span {
overflow-wrap: anywhere;
}
.canvas-empty.is-hidden {
display: none;
}
.canvas-badge {
position: absolute;
right: 14px;
bottom: 14px;
padding: 7px 10px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 999px;
color: #d7e8e2;
background: rgb(8 17 15 / 78%);
backdrop-filter: blur(10px);
font: 650 0.72rem ui-monospace, SFMono-Regular, Consolas, monospace;
pointer-events: none;
overflow: hidden;
max-width: calc(100% - 28px);
text-overflow: ellipsis;
white-space: nowrap;
}
.source-strip-heading {
margin-top: 32px;
}
.source-strip-heading h2,
.asset-shelf h2 {
margin-bottom: 5px;
font-size: 1rem;
}
.source-strip-heading p,
.asset-shelf p {
margin-bottom: 0;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.55;
}
.source-strip {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 11px;
margin-top: 13px;
}
.source-card {
display: grid;
gap: 10px;
padding: 15px;
border: 1px solid var(--line);
border-radius: 15px;
color: inherit;
background: rgb(255 255 255 / 2%);
text-align: left;
cursor: pointer;
}
.source-card:hover,
.source-card:focus-visible,
.source-card.is-selected {
border-color: var(--source-color, var(--mint));
outline: none;
background: rgb(255 255 255 / 5%);
}
.source-card__heading {
display: flex;
align-items: center;
gap: 8px;
}
.source-card__dot {
width: 10px;
height: 10px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--source-color, var(--mint));
box-shadow: 0 0 0 4px color-mix(in srgb, var(--source-color, var(--mint)) 16%, transparent);
}
.source-card strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-card small {
color: var(--muted);
font: 600 0.72rem ui-monospace, SFMono-Regular, Consolas, monospace;
}
.asset-shelf {
align-items: flex-start;
margin-top: 28px;
padding: 20px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgb(255 255 255 / 2%);
}
.asset-shelf > div:first-child {
max-width: 470px;
}
.asset-list {
display: flex;
flex: 1 1 320px;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.asset-chip {
display: flex;
align-items: center;
gap: 8px;
max-width: 100%;
padding: 8px 9px 8px 11px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
}
.asset-chip__label {
overflow: hidden;
max-width: 210px;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.74rem;
}
.asset-chip__state {
color: var(--muted);
font-size: 0.66rem;
}
.asset-chip__remove {
display: grid;
width: 23px;
height: 23px;
padding: 0;
border: 0;
border-radius: 50%;
place-items: center;
color: var(--muted);
background: rgb(255 255 255 / 5%);
cursor: pointer;
}
.asset-chip__remove:disabled {
cursor: not-allowed;
opacity: 0.35;
}
.control-panel {
position: sticky;
top: 65px;
align-self: start;
overflow-y: auto;
max-height: calc(100vh - 65px);
padding: 18px;
border-left: 1px solid var(--line);
background: rgb(12 24 21 / 86%);
backdrop-filter: blur(18px);
}
.runtime-line {
justify-content: space-between;
margin-bottom: 12px;
}
.save-state {
color: var(--muted);
font-size: 0.7rem;
}
.control-section,
.validation-card {
padding: 17px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgb(255 255 255 / 2%);
}
.control-section + .control-section,
.validation-card {
margin-top: 12px;
}
.section-title {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 14px;
}
.section-title > span {
color: var(--mint);
font: 750 0.68rem ui-monospace, SFMono-Regular, Consolas, monospace;
}
.section-title h2 {
margin: 0;
font-size: 0.9rem;
}
.field {
display: grid;
min-width: 0;
gap: 6px;
}
.field > span {
color: var(--muted);
font-size: 0.69rem;
font-weight: 650;
}
.field input,
.field select {
width: 100%;
min-height: 38px;
padding: 8px 9px;
border: 1px solid var(--line);
border-radius: 9px;
color: var(--text);
background: #0b1714;
}
.field input:focus,
.field select:focus {
border-color: var(--mint);
outline: 2px solid rgb(101 242 191 / 11%);
}
.field input:disabled,
.field select:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.check-field {
display: flex;
align-items: center;
align-self: end;
min-height: 38px;
gap: 8px;
color: var(--muted);
font-size: 0.72rem;
font-weight: 650;
}
.check-field input {
width: 17px;
height: 17px;
accent-color: var(--mint);
}
.field--wide + .field--wide,
.field-grid + .field--wide,
.field--wide + .field-grid,
#tone-fields,
#noise-fields,
#asset-fields,
.button--full {
margin-top: 10px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 10px;
}
.field-grid--three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.inline-note {
padding: 12px;
border: 1px dashed var(--line);
border-radius: 10px;
color: var(--muted);
font-size: 0.78rem;
text-align: center;
}
.section-note {
margin: 11px 0 0;
color: var(--muted);
font-size: 0.68rem;
line-height: 1.5;
}
.is-hidden {
display: none !important;
}
.validation-card {
display: flex;
align-items: flex-start;
gap: 11px;
}
.validation-dot {
width: 10px;
height: 10px;
flex: 0 0 auto;
margin-top: 4px;
border-radius: 50%;
background: var(--muted);
box-shadow: 0 0 0 5px rgb(145 170 162 / 8%);
}
.validation-card strong {
font-size: 0.82rem;
}
.validation-card p {
margin: 4px 0 0;
color: var(--muted);
font-size: 0.72rem;
line-height: 1.5;
}
.validation-card.is-valid .validation-dot {
background: var(--mint);
box-shadow: 0 0 0 5px rgb(101 242 191 / 10%);
}
.validation-card.is-invalid .validation-dot {
background: var(--danger);
box-shadow: 0 0 0 5px rgb(255 123 115 / 10%);
}
@media (max-width: 1100px) {
.builder-layout {
grid-template-columns: 1fr;
}
.control-panel {
position: static;
max-height: none;
border-top: 1px solid var(--line);
border-left: 0;
}
}
@media (max-width: 760px) {
.builder-header,
.workspace-heading,
.source-strip-heading,
.asset-shelf {
align-items: stretch;
flex-direction: column;
}
.builder-header {
position: static;
}
.header-actions .button,
.workspace-tools .button {
min-width: 0;
flex: 1 1 calc(50% - 5px);
}
.header-actions,
.workspace-tools {
width: 100%;
}
.scene-workspace {
padding: 18px;
}
.canvas-card {
min-height: 420px;
}
#scene-canvas {
height: 420px;
}
.asset-list {
justify-content: flex-start;
}
}
@media (max-width: 430px) {
.field-grid--three {
grid-template-columns: 1fr;
}
}
+38 -1
View File
@@ -1,3 +1,40 @@
# Array Geometries
재현 가능한 마이크 배열 JSON 예제를 둡니다.
Camera에서 즉시 사용하고 테스트에서 재현할 수 있는 계약 버전 `1.0.0`의 192채널 Array JSON
fixture입니다. 좌표 단위는 m이고 모든 microphone은 배열 평면 `z = 0`에 있습니다. `+x`는 오른쪽,
`+y`는 위, `+z`는 scene 방향입니다.
## 포함된 배열
### `rectangular-16x12.array.json`
- 16 columns × 12 rows = 192 microphones
- 폭과 높이가 각각 0.45 m인 정사각 aperture
- x/y 양 끝을 포함해 균일한 간격으로 배치
- 기본 single-tone experiment와 CPU/WebGPU 기준 fixture가 참조하는 baseline
### `fermat-spiral-192.array.json`
- 192 microphones
- 반지름 0.225 m, 지름 0.45 m인 원형 aperture
- golden angle과 면적 균등 반지름으로 만든 Fermat spiral
- rectangular baseline과 sidelobe·집중도·위치 오차를 비교하기 위한 preset
Acoustic Camera의 **비교 배열**에서 두 배열을 선택해 같은 Scene/Experiment 결과를 세션 기록에
나란히 추가할 수 있습니다.
## fixture 다시 생성하기
저장소 최상위에서 다음 명령을 실행하면 두 JSON을 결정론적으로 다시 생성합니다.
```powershell
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\generate-array-fixture.ps1
```
이 스크립트는 기존 `rectangular-16x12.array.json`과 `fermat-spiral-192.array.json`을 덮어씁니다.
`ExecutionPolicy Bypass`는 이 PowerShell 프로세스에만 적용됩니다. 생성 뒤
<http://127.0.0.1:4173/tests/>에서 schema와 192채널 배치 검증을 실행하세요.
사용자 배열은 [`../../schemas/array.schema.json`](../../schemas/array.schema.json) 구조를 따르고
고유한 `id`, aperture와 microphone별 `[x, y, z]`를 가져야 합니다. 선택적인 `sensitivityDb`와
`phaseOffsetDeg`는 합성 channel 편차와 beamforming 역 calibration에 사용됩니다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -1,3 +1,12 @@
# Sample Scenes
재배포 가능한 최소 synthetic scene과 fixture를 둡니다.
재배포 가능한 최소 synthetic fixture입니다. 외부 asset이 없어 서버를 시작한 직후 Camera에서
바로 실행할 수 있습니다.
- `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
두 문서는 계약 버전 `1.0.0`이며 기본 Camera 실행, CPU golden test, CPU/WebGPU 비교와 benchmark가
같은 조건을 공유하도록 고정되어 있습니다. fixture를 바꿀 때는 <http://127.0.0.1:4173/tests/>의
전체 테스트와 benchmark 입력 의미를 함께 확인하세요.
@@ -0,0 +1,28 @@
{
"schemaVersion": "1.0.0",
"kind": "micarrsim.experiment",
"id": "single-tone-50x50",
"name": "1 kHz · 50×50 baseline",
"arrayId": "rectangular-16x12",
"sceneId": "single-tone",
"scan": {
"z": 2,
"xMin": -0.6,
"xMax": 0.6,
"yMin": -0.45,
"yMax": 0.45,
"columns": 50,
"rows": 50
},
"frequencyBands": [
{ "id": "tone-1k", "minHz": 900, "maxHz": 1100, "color": "#65f2bf" }
],
"stft": { "fftSize": 512, "hopSize": 256, "window": "hann" },
"beamforming": {
"algorithm": "das",
"backend": "auto",
"minimumDistance": 0.01,
"normalization": "peak",
"dynamicRangeDb": 30
}
}
@@ -0,0 +1,25 @@
{
"schemaVersion": "1.0.0",
"kind": "micarrsim.scene",
"id": "single-tone",
"name": "1 kHz 단일 음원",
"description": "CPU/GPU 위치 추정 회귀 테스트용 자유음장 장면",
"units": "m",
"sampleRate": 48000,
"duration": 0.25,
"speedOfSound": 343,
"seed": 20260828,
"mix": { "normalization": "none", "targetPeak": 0.95 },
"sources": [
{
"id": "source-1",
"name": "1 kHz tone",
"position": [0.08, 0.04, 2],
"gainDb": -6,
"startTime": 0,
"duration": 0.25,
"signal": { "type": "tone", "frequency": 1000, "phaseDeg": 0 }
}
],
"ambient": []
}
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebGPU scaling benchmark · micarrsim</title>
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
<style>
.benchmark-shell { width: min(980px, calc(100% - 32px)); margin: 40px auto; }
.benchmark-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 20px 0; }
.benchmark-output { min-height: 240px; padding: 16px; overflow: auto; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); white-space: pre-wrap; }
button { padding: 10px 16px; border: 1px solid var(--line); border-radius: 999px; color: var(--text); background: var(--surface); cursor: pointer; }
button:disabled { cursor: wait; opacity: .55; }
</style>
</head>
<body>
<main class="benchmark-shell">
<p class="eyebrow">192-channel reproducible benchmark</p>
<h1>WebGPU DAS scaling</h1>
<p id="benchmark-status">fixture를 준비하고 있습니다…</p>
<div class="benchmark-actions">
<button id="run-benchmark" type="button">다시 실행</button>
<button id="download-result" type="button" disabled>JSON 저장</button>
<a href="/tests/">전체 테스트 열기</a>
</div>
<pre class="benchmark-output" id="benchmark-output" aria-live="polite"></pre>
</main>
<script type="module" src="/benchmarks/webgpu-scaling.js"></script>
</body>
</html>
+183
View File
@@ -0,0 +1,183 @@
import { distance3 } from "/packages/acoustics-core/math.js";
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
import { computeDasWebGpu } from "/packages/webgpu-kernels/das.js";
const status = document.querySelector("#benchmark-status");
const output = document.querySelector("#benchmark-output");
const runButton = document.querySelector("#run-benchmark");
const downloadButton = document.querySelector("#download-result");
const query = new URLSearchParams(location.search);
const requestedRuns = Number.parseInt(query.get("runs") ?? "3", 10);
const runCount = Number.isInteger(requestedRuns) ? Math.min(5, Math.max(1, requestedRuns)) : 3;
let latestResult = null;
async function loadJson(path) {
const response = await fetch(path);
if (!response.ok) throw new Error(`${path}를 불러오지 못했습니다 (${response.status}).`);
return response.json();
}
function median(values) {
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
}
function round(value, digits = 3) {
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
}
function cloneExperiment(experiment, size) {
return {
...experiment,
id: `single-tone-${size}x${size}`,
scan: { ...experiment.scan, columns: size, rows: size },
beamforming: { ...experiment.beamforming, backend: "webgpu" },
};
}
function downloadJson(value, filename) {
const blob = new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
async function runBenchmark() {
runButton.disabled = true;
downloadButton.disabled = true;
document.documentElement.dataset.benchmarkStatus = "running";
delete document.documentElement.dataset.benchmarkError;
latestResult = null;
globalThis.__micarrsimBenchmarkResult = null;
output.textContent = "";
try {
const [arrayDefinition, scene, baseExperiment] = 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"),
]);
status.textContent = "192채널 마이크 신호를 한 번 합성하고 있습니다…";
const signalStartedAt = performance.now();
const synthesis = synthesizeMicrophoneSignals({ scene, arrayDefinition });
const signalGenerationMs = performance.now() - signalStartedAt;
let adapterInfo = null;
status.textContent = "WebGPU pipeline을 예열하고 있습니다…";
await computeDasWebGpu({
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
scene,
experiment: cloneExperiment(baseExperiment, 16),
});
const resolutions = [];
for (const size of [50, 100, 200]) {
const samples = [];
for (let runIndex = 0; runIndex < runCount; runIndex += 1) {
status.textContent = `${size}×${size} · ${runIndex + 1}/${runCount} 실행 중…`;
const result = await computeDasWebGpu({
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
scene,
experiment: cloneExperiment(baseExperiment, size),
onProgress: ({ stage, completedUnits, totalUnits }) => {
if (stage === "beamforming" && totalUnits) {
status.textContent =
`${size}×${size} · ${runIndex + 1}/${runCount} · GPU ${completedUnits}/${totalUnits}`;
}
},
});
adapterInfo = result.gpu.adapterInfo;
const localizationErrorM = distance3(result.bands[0].peak.position, scene.sources[0].position);
samples.push({
totalMs: result.timingMs.total,
stftMs: result.timingMs.stft,
gpuWallMs: result.timingMs.gpuWall,
beamformingMs: result.timingMs.beamforming,
localizationErrorM,
complexMacCount: result.workload.complexMacCount,
estimatedGpuWorkingBytes: result.gpu.batchPlan.estimatedGpuWorkingBytes,
binBatchSize: result.gpu.batchPlan.binBatchSize,
pointBatchSize: result.gpu.batchPlan.pointBatchSize,
});
}
resolutions.push({
grid: `${size}x${size}`,
pointCount: size * size,
runs: runCount,
medianTotalMs: round(median(samples.map((sample) => sample.totalMs))),
medianStftMs: round(median(samples.map((sample) => sample.stftMs))),
medianGpuWallMs: round(median(samples.map((sample) => sample.gpuWallMs))),
medianBeamformingMs: round(median(samples.map((sample) => sample.beamformingMs))),
localizationErrorM: round(median(samples.map((sample) => sample.localizationErrorM)), 6),
complexMacCount: samples[0].complexMacCount,
estimatedGpuWorkingMiB: round(samples[0].estimatedGpuWorkingBytes / 1024 ** 2),
binBatchSize: samples[0].binBatchSize,
pointBatchSize: samples[0].pointBatchSize,
samples: samples.map((sample) => ({
totalMs: round(sample.totalMs),
stftMs: round(sample.stftMs),
gpuWallMs: round(sample.gpuWallMs),
beamformingMs: round(sample.beamformingMs),
})),
});
output.textContent = JSON.stringify(resolutions, null, 2);
}
const baselineGpuMs = resolutions[0].medianGpuWallMs;
for (const resolution of resolutions) {
resolution.gpuWallScalingVs50 = round(resolution.medianGpuWallMs / baselineGpuMs, 2);
}
latestResult = {
schemaVersion: "1.0.0",
kind: "micarrsim.webgpu-benchmark",
createdAt: new Date().toISOString(),
fixture: {
arrayId: arrayDefinition.id,
microphoneCount: arrayDefinition.microphones.length,
sceneId: scene.id,
durationSeconds: scene.duration,
sampleRate: scene.sampleRate,
fftSize: baseExperiment.stft.fftSize,
hopSize: baseExperiment.stft.hopSize,
selectedBandHz: baseExperiment.frequencyBands.map(({ minHz, maxHz }) => [minHz, maxHz]),
},
environment: {
userAgent: navigator.userAgent,
adapterInfo,
timingKind: "wall-clock",
note: "GPU wall time starts after host packing/writeBuffer calls and waits for queued transfers, two compute passes, and readback; total also includes device/pipeline setup and CPU STFT.",
},
signalGenerationMs: round(signalGenerationMs),
warmupGrid: "16x16",
resolutions,
};
output.textContent = JSON.stringify(latestResult, null, 2);
globalThis.__micarrsimBenchmarkResult = latestResult;
document.documentElement.dataset.benchmarkStatus = "pass";
status.textContent = "benchmark 완료";
downloadButton.disabled = false;
} catch (error) {
document.documentElement.dataset.benchmarkStatus = "fail";
document.documentElement.dataset.benchmarkError = error.message;
status.textContent = `benchmark 실패: ${error.message}`;
output.textContent = error instanceof Error ? error.stack : String(error);
throw error;
} finally {
runButton.disabled = false;
}
}
runButton.addEventListener("click", () => runBenchmark().catch(() => {}));
downloadButton.addEventListener("click", () => {
if (latestResult) downloadJson(latestResult, "micarrsim-webgpu-benchmark.json");
});
if (query.get("autorun") !== "0") runBenchmark().catch(() => {});
+24
View File
@@ -0,0 +1,24 @@
# WebGPU 기준 실측
2026-08-28에 Chrome 151 headless, Intel Gen9 adapter에서 synthetic 1 kHz fixture를 측정했다. 입력은
48 kHz · 0.25 s, 192 microphones, FFT 512 / hop 256, 900–1100 Hz band이며 16×16 warm-up 뒤 각
해상도를 3회 실행한 중앙값이다.
| scan grid | complex MAC | GPU wall median | end-to-end median | working set | 50² 대비 GPU wall |
| --- | ---: | ---: | ---: | ---: | ---: |
| 50×50 | 44,160,000 | 79.2 ms | 1,818.1 ms | 9.33 MiB | 1.00× |
| 100×100 | 176,640,000 | 251.4 ms | 2,102.0 ms | 36.91 MiB | 3.17× |
| 200×200 | 706,560,000 | 891.9 ms | 4,206.7 ms | 80.47 MiB | 11.26× |
`GPU wall`은 host packing/`writeBuffer()` 호출 직후부터 queue에 들어간 transfer 완료, steering pass,
DAS pass와 readback까지 기다린 시간이다. `end-to-end`에는 이 host 작업과 장치/pipeline 초기화,
distance 생성, CPU STFT가 포함된다. 200×200은 64 MiB steering buffer 목표를 지키면서 두 FFT bin을
한 번에 유지하고 scan point를 21,845개씩 나눈다. 따라서 point chunk가 필요해도 CPU FFT를 다시
계산하지 않는다. 절대 시간은 브라우저, driver와 GPU에 따라 달라지며 자동 성능 통과/실패 기준으로
쓰지 않는다.
같은 환경의 자동 검증에서 192채널·50×50 GPU raw map은 CPU raw map 대비 peak-normalized RMS
오차 `3.008e-5`를 기록했고, 측정 당시 제공되던 37개 테스트를 모두 통과했다. 이후 end-to-end와
서버 보안 테스트가 추가되었으므로 현재 전체 suite 상태는 `/tests/`에서 별도로 확인한다. 상세
원자료는 `webgpu-intel-gen9-2026-08-28.json`에 있다. 사용자는
`/benchmarks/webgpu-scaling.html`에서 자신의 장치 결과를 다시 측정해 JSON으로 저장할 수 있다.
@@ -0,0 +1,90 @@
{
"schemaVersion": "1.0.0",
"kind": "micarrsim.webgpu-benchmark",
"createdAt": "2026-08-28T02:24:00.354Z",
"fixture": {
"arrayId": "rectangular-16x12",
"microphoneCount": 192,
"sceneId": "single-tone",
"durationSeconds": 0.25,
"sampleRate": 48000,
"fftSize": 512,
"hopSize": 256,
"selectedBandHz": [[900, 1100]]
},
"environment": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36",
"adapterInfo": {
"vendor": "intel",
"architecture": "gen-9",
"device": "",
"description": ""
},
"timingKind": "wall-clock",
"note": "GPU wall time starts after host packing/writeBuffer calls and waits for queued transfers, two compute passes, and readback; total also includes device/pipeline setup and CPU STFT."
},
"signalGenerationMs": 1200.5,
"warmupGrid": "16x16",
"resolutions": [
{
"grid": "50x50",
"pointCount": 2500,
"runs": 3,
"medianTotalMs": 1818.1,
"medianStftMs": 1669.8,
"medianGpuWallMs": 79.2,
"medianBeamformingMs": 162.1,
"localizationErrorM": 0.008227,
"complexMacCount": 44160000,
"estimatedGpuWorkingMiB": 9.33,
"binBatchSize": 2,
"pointBatchSize": 2500,
"samples": [
{ "totalMs": 1907.7, "stftMs": 1727.7, "gpuWallMs": 82.5, "beamformingMs": 180 },
{ "totalMs": 1795.7, "stftMs": 1633.6, "gpuWallMs": 79.2, "beamformingMs": 162.1 },
{ "totalMs": 1818.1, "stftMs": 1669.8, "gpuWallMs": 79.1, "beamformingMs": 148.3 }
],
"gpuWallScalingVs50": 1
},
{
"grid": "100x100",
"pointCount": 10000,
"runs": 3,
"medianTotalMs": 2102,
"medianStftMs": 1681.1,
"medianGpuWallMs": 251.4,
"medianBeamformingMs": 537.8,
"localizationErrorM": 0.001515,
"complexMacCount": 176640000,
"estimatedGpuWorkingMiB": 36.91,
"binBatchSize": 2,
"pointBatchSize": 10000,
"samples": [
{ "totalMs": 2102, "stftMs": 1564.2, "gpuWallMs": 251.4, "beamformingMs": 537.8 },
{ "totalMs": 2220.1, "stftMs": 1681.1, "gpuWallMs": 254.7, "beamformingMs": 539 },
{ "totalMs": 2098.8, "stftMs": 1685, "gpuWallMs": 220.4, "beamformingMs": 413.8 }
],
"gpuWallScalingVs50": 3.17
},
{
"grid": "200x200",
"pointCount": 40000,
"runs": 3,
"medianTotalMs": 4206.7,
"medianStftMs": 2057.5,
"medianGpuWallMs": 891.9,
"medianBeamformingMs": 2039,
"localizationErrorM": 0.002099,
"complexMacCount": 706560000,
"estimatedGpuWorkingMiB": 80.468,
"binBatchSize": 2,
"pointBatchSize": 21845,
"samples": [
{ "totalMs": 4206.7, "stftMs": 2057.5, "gpuWallMs": 891.9, "beamformingMs": 2149.2 },
{ "totalMs": 3388.9, "stftMs": 1686.9, "gpuWallMs": 808.5, "beamformingMs": 1702 },
{ "totalMs": 6227.8, "stftMs": 4188.8, "gpuWallMs": 939.7, "beamformingMs": 2039 }
],
"gpuWallScalingVs50": 11.26
}
]
}
+88
View File
@@ -0,0 +1,88 @@
# 데이터 계약
micarrsim의 저장 형식은 JSON Schema Draft 2020-12와 추가 관계 검증으로 정의한다. 모든 길이는 m,
시간은 s, 주파수는 Hz다. 배열 중심이 원점이고 `+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서
장면을 향하는 우수 좌표계를 사용한다.
## 파일 종류
- `micarrsim.array`: aperture와 마이크별 `[x, y, z]` 좌표
- `micarrsim.scene`: 음원·ambient recipe, 위치 ground truth와 로컬 asset manifest
- `micarrsim.experiment`: scan plane/grid, 주파수 대역, STFT와 DAS 설정
- `micarrsim.result`: raw/display acoustic map, 복수 peak와 source 매칭, 평가·성능 정보
- `micarrsim.comparison-history`: 세션의 배열별 Result 요약을 모은 편의 export. core schema
검증 대상은 아니며 각 원본 Result JSON을 대신하지 않는다.
scene JSON에는 192채널 원시 sample이나 원본 asset byte를 넣지 않는다. 합성 tone/noise는 seed가
포함된 recipe로 재생성하고, 녹음 파일과 이미지는 `id`, `kind`, `fileName`, `mimeType`,
`sha256` manifest로 참조한다. 사용자 컴퓨터의 절대 경로, URL, `..` 경로는 저장하지 않는다.
array의 `sensitivityDb`와 `phaseOffsetDeg`는 각 마이크 수신 channel에 적용되는 편차다. 합성기는
이를 신호에 주입하고, beamformer는 알려진 calibration 값의 역 gain/phase를 적용한다. 따라서
두 값이 0이 아닌 배열도 오류 없이 조용히 무시되지 않는다.
## 로컬 asset 재연결
브라우저 보안상 Scene JSON을 새 페이지에서 열었을 때 이전 로컬 파일 권한은 복원되지 않는다.
Camera는 사용자가 다시 선택한 파일을 SHA-256으로 계산해 같은 descriptor에만 연결한다. 이름이
같아도 byte가 다르면 거절하고, 원본은 브라우저 메모리에서만 decode하며 서버에 업로드하지 않는다.
- 파일 하나: 최대 128 MiB
- 한 Scene에 연결된 원본 파일 합계: 최대 512 MiB
- 동시에 유지하는 decode 오디오 channel 합계: 최대 192 MiB
Builder가 내보낸 Scene JSON과 사용한 원본 이미지·오디오는 함께 보관해야 한다. Preview WAV는
scene recipe를 mono로 합친 청취 확인용 파일이며 마이크 배열의 192채널 수신 신호가 아니다.
## 오디오와 map 정규화
`scene.mix.normalization`은 마이크 신호 합성 전에 적용할 오디오 mix 정책이다. `none`은 원래
gain을 보존하고, `peak`는 합성 전 peak를 `targetPeak`에 맞춘다. result의 `signalStats`에는
정규화 전 peak, 실제 scale, scale 적용 후에도 범위를 넘은 sample 수를 기록한다.
이는 acoustic map 표시 정규화와 별개다. result의 `rawMap`은 반드시 선형 power이고,
`displayMap`은 peak-normalized 값 또는 dB 값일 수 있다. 후처리 결과는 `processedMaps`에 넣어
raw DAS map을 덮어쓰지 않는다.
map은 row-major 배열이다. 첫 행은 `yMax`, 마지막 행은 `yMin`, 각 행은 `xMin`에서 `xMax`
방향이다. `values.length`는 반드시 `rows × columns`와 같아야 한다.
## Result 평가 정보
각 band의 `peaks`는 위치와 power 값, 선택적 `sourceId`, `localizationErrorM`,
`localizationErrorPixels`를 저장한다. `metrics`에는 다음 유한값만 기록한다.
- `peakToSidelobeDb`
- `energyInsideTruthRatio`
- `precision`, `recall`
- `mainLobeWidthM`
`evaluation`은 지표를 다시 해석할 수 있도록 truth 반경, source 매칭 tolerance, peak 검출
threshold와 최소 거리, sidelobe 제외 반경, half-power 비율, dynamic range, 최대 검출 수를
버전과 함께 저장한다. `environment`에는 실제 backend, microphone/grid 수, scan bounds와 z,
가능한 경우 GPU adapter 및 fallback 정보를 기록한다.
대역별 ground truth는 tone의 주파수가 band 안에 있을 때 그 tone source를 포함한다. noise와
asset source는 현재 모든 band의 truth로 간주한다. 즉 녹음 asset의 실제 spectrum energy를
대역별로 판정하는 계약은 아직 없으며, 이 가정은 실제 데이터 결과를 해석할 때 고려해야 한다.
## 재현성 hash
`result.inputHash`는 실행에 사용한 `{ array, scene, experiment }`를 객체 key 사전순으로 재귀
정렬하고 공백 없이 JSON 직렬화한 UTF-8 byte의 SHA-256 소문자 hex다. 사용한 asset은 실행 전에
SHA-256을 계산해 scene의 해당 asset `sha256` 필드를 채운 상태로 canonical input을 만든다.
따라서 파일 이름이 같아도 내용이 바뀌면 hash가 달라진다.
## 버전과 검증
현재 계약 버전은 `1.0.0`이다. 브라우저는 `packages/contracts/schema-validator.js`로 저장소의
실제 schema를 실행한 뒤 `validation.js`에서 다음 관계를 추가 검사한다.
- 중복 id와 array/scene/experiment/result 참조
- asset 종류와 안전한 상대 경로
- Nyquist 및 시간·scan 범위
- map shape와 source peak 매칭
- band별 evaluation 파라미터와 허용 metric
- 현재 비스트리밍 CPU backend의 512 MiB channel-buffer 예산
지원하지 않는 schemaVersion은 묵시적으로 변환하지 않고 오류로 알린다.
+41 -28
View File
@@ -1,5 +1,10 @@
# micarrsim 실행 계획
> 구현 상태 · 2026-08-28
>
> 단계 0–4는 완료되어 설치 없는 브라우저 앱, CPU/WebGPU DAS, Builder→Camera asset 재연결,
> 배열 비교, 평가·Result 내보내기와 53개 자동 테스트로 검증했다. 단계 5는 향후 고도화 범위다.
## 1. 목표와 성공 기준
이 프로젝트는 정방형 평면에 배치할 192개 MEMS 마이크의 배열을 비교하기 위한 재현 가능한
@@ -8,7 +13,7 @@
1. 기계음 데이터와 ambient audio를 조합해 장면 및 ground truth를 생성한다.
2. 배열 JSON과 장면 정보를 사용해 각 마이크에 도달하는 신호를 합성한다.
3. WGSL로 conventional delay-and-sum(DAS/DAC) 빔포밍을 수행한다.
3. WGSL로 conventional delay-and-sum(DAS) 빔포밍을 수행한다.
4. 주파수 구간별 acoustic image를 ground truth 위에 표시하고 정량 평가한다.
5. 배열, 거리, 음원, 주파수, grid 해상도를 바꾼 실험을 저장하고 비교한다.
@@ -18,10 +23,12 @@ CPU 기준 구현과 GPU 결과의 수치 오차가 합의된 허용 범위 안
## 2. 기술 접근 결정
TypeScript 기반의 브라우저 앱 2개와 WebGPU/WGSL을 권장한다. 브라우저는 배열·장면 편집,
오디오 미리 듣기, 이미지 overlay에 적합하고 WebGPU는 192 × scan point × frequency bin 형태의
병렬 연산에 잘 맞는다. WGSL 커널을 독립 패키지로 두면 이후 GLSL 또는 네이티브 GPU 구현으로
옮길 때 데이터 배치와 수식을 재사용하기 쉽다.
최종 구현은 빌드 단계와 패키지 설치가 없는 브라우저 네이티브 ES module 2개와 WebGPU/WGSL을
선택했다. 초기 계획의 TypeScript 권고보다 Windows 사용자가 `start.cmd` 하나로 바로 실행하는
목표를 우선한 결정이다. JSON Schema와 브라우저 자동 테스트가 런타임 계약을 보완한다.
브라우저는 배열·장면 편집, 오디오 미리 듣기, 이미지 overlay에 적합하고 WebGPU는
192 × scan point × frequency bin 형태의 병렬 연산에 잘 맞는다. WGSL 커널은 독립 package로
두어 데이터 배치와 수식을 CPU 기준 구현과 대조할 수 있게 했다.
데이터셋 전체를 브라우저에 적재하지 않는다. 사용자가 로컬 파일을 선택해 장면을 만들고,
결과를 manifest와 오디오 파일로 내보내는 방식을 우선 구현한다. 대규모 batch 전처리가 필요해질
@@ -73,8 +80,9 @@ DAS는 STFT 후 각 scan point와 마이크 사이 거리로 steering phase를
## 5. UI 계획
`acoustic-camera`는 폭 600px 이상인 좌측 scene canvas에 기기 이미지, ground-truth hint,
주파수별 acoustic overlay를 표시한다. 우측에는 배열 geometry를 확대·회전 가능한 평면도로
표시하고 그 아래에 제어 패널을 둔다. 작은 화면에서는 두 열을 세로로 쌓는다.
주파수별 acoustic overlay를 표시한다. 우측에는 배열 geometry를 좌표와 함께 정적 평면도로
표시하고 그 아래에 제어 패널을 둔다. 작은 화면에서는 두 열을 세로로 쌓는다. 확대·회전 상호작용은
향후 필요성 검증 뒤 추가한다.
필수 입력은 음원 수와 위치, AC 거리, 다중 주파수 구간과 색, ambient 파일, scan grid 크기,
후처리 선택이다. 여기에 sample rate, 음속/온도, STFT window·hop, scan 영역, dynamic range,
@@ -105,16 +113,16 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
## 8. 단계별 구현
### 단계 0 — 기반과 계약
### 단계 0 — 기반과 계약 · 완료
- workspace, formatter, linter, unit test, WebGPU feature detection 구성
- 무설치 ES module workspace, browser test runner, WebGPU feature detection 구성
- 네 가지 JSON Schema와 최소 예제 작성
- 좌표계·단위·오디오 normalization 결정 기록
- synthetic single-tone scene과 192채널 기준 배열 작성
완료 조건: 예제 JSON이 schema validation을 통과하고 두 앱이 동일 manifest를 읽는다.
### 단계 1 — CPU 기준 모델
### 단계 1 — CPU 기준 모델 · 완료
- 자유음장 구면 전파, 분수 지연, STFT, steering, DAS의 CPU reference 구현
- 단일 음원의 지연·감쇠·peak 위치를 analytic result와 비교
@@ -122,15 +130,15 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
완료 조건: 자동화된 golden test가 통과하고 sign/좌표 convention이 문서화된다.
### 단계 2 — Dataset Builder
### 단계 2 — Dataset Builder · 완료
- 이미지와 음원 파일 불러오기, 위치·시간·gain 편집, ambient mix와 미리 듣기
- deterministic seed, clipping 경고, manifest/audio 내보내기
- 데이터셋 adapter 인터페이스와 synthetic adapter 구현
- deterministic seed, clipping 경고, Scene JSON과 mono Preview WAV 내보내기
- synthetic tone/noise fixture와 로컬 asset manifest 경계 구현
완료 조건: 만든 scene을 다시 열었을 때 음원, 위치, 파형, 정답이 동일하다.
### 단계 3 — WebGPU DAS
### 단계 3 — WebGPU DAS · 완료
- CPU와 동일한 buffer layout 및 WGSL kernel 구현
- frequency band별 map 생성, device limit 대응, CPU fallback
@@ -138,7 +146,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
완료 조건: CPU/GPU map 오차 기준을 통과하고 192채널 50×50 실행 timing이 기록된다.
### 단계 4 — Acoustic Camera UI와 평가
### 단계 4 — Acoustic Camera UI와 평가 · 완료
- scene/ground truth/acoustic overlay, 배열 geometry, parameter panel 구현
- 결과 metric, 실행 환경, 입력 hash와 map 내보내기
@@ -146,7 +154,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
완료 조건: 사용자가 코드 수정 없이 배열 JSON 두 개를 같은 장면에서 비교할 수 있다.
### 단계 5 — 실제 데이터와 고도화
### 단계 5 — 실제 데이터와 고도화 · 향후
- 라이선스 검토를 통과한 DCASE/MIMII adapter와 batch workflow 추가
- microphone calibration·noise 모델 검토
@@ -159,20 +167,25 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
- WebGPU 미지원/장치 한도: 시작 시 capability를 표시하고 CPU fallback 및 grid 상한을 둔다.
- 좌표·phase 부호 오류: analytic tone test와 배열 대칭성 test를 먼저 만든다.
- 브라우저 메모리: 전체 시간×192 신호를 항상 보관하지 않고 chunk/STFT frame 단위로 처리한다.
- 데이터셋 부적합/라이선스: adapter 경계를 유지하고 synthetic fixture를 기본 CI 입력으로 쓴다.
- 브라우저 메모리: 현재 전체 192채널 signal buffer에 512 MiB 한도를 적용한다. 장시간 입력은
향후 chunk/STFT streaming backend로 확장한다.
- 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로
쓴다. 실제 데이터 adapter는 단계 5의 라이선스 검토 뒤 추가한다.
- 실제 AC와 simulation gap: 감도, 위상, 위치 오차를 이후 calibration/noise model로 주입 가능하게 한다.
- post-processing이 기본 결과를 가림: raw DAS map과 후처리 map을 항상 별도로 보존한다.
## 10. 첫 구현 백로그
## 10. 구현 결과와 다음 백로그
1. TypeScript workspace와 두 앱 shell을 생성한다.
2. array/scene/experiment/result schema와 fixture를 만든다.
3. 좌표 변환, 거리, 구면 감쇠, 주파수 phase delay 단위 테스트를 작성한다.
4. CPU DAS golden implementation과 synthetic scene을 만든다.
5. WGSL kernel 및 CPU/GPU 비교 harness를 만든다.
6. scene canvas, array view, parameter controls를 연결한다.
7. benchmark와 평가 지표를 결과 manifest에 기록한다.
- [x] 설치 없는 ES module workspace와 두 앱 shell
- [x] array/scene/experiment/result schema와 synthetic fixture
- [x] 좌표·거리·구면 감쇠·분수 지연·STFT 단위 테스트
- [x] CPU DAS golden implementation
- [x] WGSL kernel, CPU/GPU 비교와 50²/100²/200² benchmark
- [x] scene canvas, array view, parameter controls와 Result export
- [x] Builder→Camera SHA-256 asset 재연결 왕복 테스트
- [ ] DCASE/MIMII 라이선스 검토와 adapter
- [ ] 잔향·calibration·noise model
- [ ] DAMAS/CLEAN-SC 후처리
각 단계는 정확성 test를 먼저 통과시킨 뒤 UI 또는 최적화를 진행한다. 초기 milestone에서는 잔향,
DAMAS/CLEAN-SC, 대규모 dataset batch 변환을 완료 조건으로 삼지 않는다.
정확성 test를 먼저 통과시킨 뒤 UI 또는 최적화를 진행한다는 원칙은 유지한다. 단계 5 기능은
자유음장 DAS 결과를 바꾸지 않는 선택형 확장으로 추가한다.
+199
View File
@@ -0,0 +1,199 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="micarrsim 빠른 시작, 전체 데이터 흐름, 결과 해석과 문제 해결"
/>
<title>micarrsim 사용 설명서</title>
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
<link rel="stylesheet" href="/docs/styles.css" />
</head>
<body>
<header class="guide-header">
<a class="guide-brand" href="/">← micarrsim 홈</a>
<nav aria-label="문서 목차">
<a href="#quick-start">빠른 시작</a>
<a href="#full-workflow">내 데이터</a>
<a href="#results">결과 해석</a>
<a href="#troubleshooting">문제 해결</a>
</nav>
</header>
<main class="guide-shell">
<section class="guide-hero">
<p class="eyebrow">Local user guide</p>
<h1>설치 없이 시작하는<br />음향 카메라 실험</h1>
<p>
Windows 10/11과 최신 Chrome 또는 Edge만 있으면 됩니다. 모든 입력과 계산은 이
컴퓨터의 브라우저 안에서 처리되며 외부 서버로 업로드되지 않습니다.
</p>
</section>
<section class="guide-section" id="quick-start">
<div class="section-number">01</div>
<div>
<p class="eyebrow">30초 확인</p>
<h2>기본 결과 하나 저장하기</h2>
<ol class="guide-steps">
<li><code>start.cmd</code>를 더블클릭합니다.</li>
<li>홈에서 <a href="/apps/acoustic-camera/">Acoustic Camera</a>를 엽니다.</li>
<li><strong>▶ 기본 예제 실행</strong>을 누릅니다.</li>
<li>완료되면 <strong>Result JSON</strong>을 눌러 다운로드 폴더에 저장합니다.</li>
</ol>
<aside class="callout">
브라우저가 자동으로 열리지 않으면 서버 콘솔에 표시된 주소(기본
<code>http://127.0.0.1:4173/</code>)를 입력하거나
<a href="/">현재 서버의 micarrsim 홈</a>을 여세요. 종료할 때는 서버 콘솔에서
<kbd>Ctrl</kbd>+<kbd>C</kbd>를 누릅니다.
</aside>
</div>
</section>
<section class="guide-section" id="full-workflow">
<div class="section-number">02</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를 설정합니다.
</li>
<li><strong>Scene JSON 저장</strong>을 누르고 사용한 원본 파일과 함께 보관합니다.</li>
<li><strong>Scene 저장 후 Camera 열기</strong>를 누릅니다.</li>
<li>
Camera에서 파일을 <strong>Scene → Array → Experiment → Assets</strong> 순서로
가져옵니다. 내 Array/Experiment가 없다면 내장 기본값을 그대로 둡니다.
</li>
<li>
Assets에서 Builder에 사용한 원본 이미지와 오디오를 한 번에 다시 선택합니다.
준비 상태가 모두 초록색인지 확인합니다.
</li>
<li><strong>Auto (GPU→CPU)</strong>로 실행한 뒤 Result JSON을 저장합니다.</li>
</ol>
<aside class="callout callout--important" id="asset-reconnect">
<strong>Scene JSON에는 원본 파일이 들어 있지 않습니다.</strong>
파일명·종류·SHA-256 manifest만 저장됩니다. 브라우저는 새 페이지에서 로컬 파일
권한을 유지할 수 없으므로 같은 원본을 다시 선택해야 합니다. 이름이 같아도 내용의
SHA-256이 다르면 연결되지 않습니다.
</aside>
<div class="limit-grid" aria-label="asset 한도">
<div><strong>128 MiB</strong><span>원본 파일 하나</span></div>
<div><strong>512 MiB</strong><span>연결 파일 합계</span></div>
<div><strong>192 MiB</strong><span>decode된 오디오 합계</span></div>
</div>
</div>
</section>
<section class="guide-section">
<div class="section-number">03</div>
<div>
<p class="eyebrow">Compute backend</p>
<h2>속도와 격자 선택</h2>
<div class="guide-table-wrap">
<table>
<thead><tr><th>선택</th><th>동작</th><th>권장 상황</th></tr></thead>
<tbody>
<tr><td>Auto (GPU→CPU)</td><td>WebGPU를 먼저 시도하고 불가능하면 CPU 사용</td><td>기본 권장</td></tr>
<tr><td>WebGPU 강제</td><td>GPU 장치가 없거나 실패하면 fallback 없이 오류</td><td>GPU 비교·측정</td></tr>
<tr><td>CPU 강제</td><td>동일한 자유음장 DAS를 CPU에서 계산</td><td>호환성 확인·기준 비교</td></tr>
</tbody>
</table>
</div>
<p class="guide-copy">
먼저 50×50을 권장합니다. 100×100은 4배, 200×200은 16배의 scan point를 계산합니다.
실행 중에는 취소할 수 있으며, 취소하거나 실패해도 이전 완료 결과는 유지됩니다.
</p>
</div>
</section>
<section class="guide-section" id="results">
<div class="section-number">04</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>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">
평가는 tone 음원을 해당 주파수 대역이 tone 주파수를 포함할 때만 정답으로 사용합니다.
noise와 asset 음원은 현재 모든 대역의 정답으로 간주합니다. 녹음 파일의 실제 대역별
에너지를 자동 판정하는 기능은 아직 없습니다.
</aside>
<p class="guide-copy">
Camera에서 바꾼 Scene과 Experiment는 각각 저장할 수 있습니다. 완료 Result와 배열 비교
기록도 별도 JSON으로 저장하세요. 저장하지 않은 항목이나 실행 중인 계산이 있으면
페이지를 닫을 때 브라우저가 확인합니다.
</p>
</div>
</section>
<section class="guide-section" id="troubleshooting">
<div class="section-number">05</div>
<div>
<p class="eyebrow">Troubleshooting</p>
<h2>막혔을 때 확인할 것</h2>
<details open>
<summary>화면이 비거나 module/Worker 오류가 납니다.</summary>
<p><code>index.html</code>을 직접 열지 말고 반드시 <code>start.cmd</code>로 실행하세요.</p>
</details>
<details>
<summary>4173 포트를 사용할 수 없다고 나옵니다.</summary>
<p><code>start.cmd -Port 4174</code>처럼 다른 포트를 지정하고 <code>http://127.0.0.1:4174/</code>을 여세요.</p>
</details>
<details>
<summary>Experiment를 가져올 수 없습니다.</summary>
<p>Scene과 Array를 먼저 가져오세요. Experiment의 <code>sceneId</code>와 <code>arrayId</code>가 현재 문서와 같아야 합니다.</p>
</details>
<details>
<summary>Assets가 준비되지 않습니다.</summary>
<p>Builder에서 사용한 것과 바이트가 같은 원본 파일을 다시 선택하세요. 파일명뿐 아니라 SHA-256도 일치해야 합니다.</p>
</details>
<details>
<summary>WebGPU가 없거나 실행이 너무 느립니다.</summary>
<p>Backend를 Auto 또는 CPU로 두고 50×50 grid부터 실행하세요. 최신 Chrome/Edge와 그래픽 드라이버도 확인하세요.</p>
</details>
</div>
</section>
<section class="guide-section">
<div class="section-number">06</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는 현재 범위에 포함되지 않습니다. 결과는 실제
장비의 절대 성능 보증이 아니라 같은 조건에서 배열을 비교하는 재현 가능한 기준입니다.
</p>
<p class="guide-copy">
원본 asset은 브라우저 메모리에서만 읽고 서버나 외부 네트워크로 보내지 않습니다.
다만 Scene JSON에는 원본 파일명이 남을 수 있으므로 외부 공유 전 확인하세요.
</p>
</div>
</section>
<section class="reference-grid" aria-label="상세 문서">
<a href="/README.md"><strong>전체 README</strong><span>설치·실행·구조·제약</span></a>
<a href="/apps/dataset-builder/README.md"><strong>Builder 설명서</strong><span>source·ambient·Preview WAV</span></a>
<a href="/apps/acoustic-camera/README.md"><strong>Camera 설명서</strong><span>backend·결과·비교</span></a>
<a href="/docs/data-contracts.md"><strong>데이터 계약</strong><span>JSON Schema와 재현성</span></a>
<a href="/docs/execution-plan.md"><strong>실행 계획</strong><span>구현 상태와 향후 단계</span></a>
<a href="/tests/"><strong>브라우저 테스트</strong><span>단위·통합 테스트 실행</span></a>
<a href="/benchmarks/webgpu-scaling.html"><strong>WebGPU 측정</strong><span>현재 장치 benchmark</span></a>
</section>
</main>
</body>
</html>
+291
View File
@@ -0,0 +1,291 @@
.guide-header {
position: sticky;
z-index: 10;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 64px;
padding: 12px clamp(18px, 4vw, 48px);
border-bottom: 1px solid var(--line);
background: rgb(8 17 15 / 92%);
backdrop-filter: blur(18px);
}
.guide-brand,
.guide-header nav a {
color: inherit;
font-size: 0.82rem;
font-weight: 700;
text-decoration: none;
}
.guide-header nav {
display: flex;
flex-wrap: wrap;
gap: 18px;
color: var(--muted);
}
.guide-shell {
width: min(980px, calc(100% - 40px));
margin: 0 auto;
padding: clamp(48px, 8vw, 96px) 0 72px;
}
.guide-hero {
max-width: 820px;
margin-bottom: clamp(64px, 10vw, 112px);
}
.guide-hero h1 {
margin-bottom: 24px;
font-size: clamp(2.8rem, 8vw, 6.4rem);
letter-spacing: -0.07em;
line-height: 0.98;
}
.guide-hero > p:last-child,
.guide-copy {
color: #c8d9d3;
line-height: 1.8;
}
.guide-hero > p:last-child {
max-width: 720px;
font-size: clamp(1rem, 2vw, 1.2rem);
}
.guide-section {
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
gap: clamp(18px, 4vw, 42px);
padding: clamp(36px, 7vw, 68px) 0;
border-top: 1px solid var(--line);
scroll-margin-top: 72px;
}
.section-number {
color: var(--mint);
font-size: 0.75rem;
font-weight: 760;
letter-spacing: 0.12em;
}
.guide-section h2 {
margin-bottom: 28px;
font-size: clamp(1.8rem, 4vw, 3rem);
letter-spacing: -0.05em;
}
.guide-steps {
display: grid;
gap: 14px;
margin: 0;
padding-left: 1.35rem;
color: #c8d9d3;
line-height: 1.75;
}
.guide-steps li {
padding-left: 8px;
}
.guide-steps a,
.callout a {
color: var(--mint);
}
code,
kbd {
border: 1px solid var(--line);
border-radius: 6px;
color: var(--text);
background: rgb(255 255 255 / 5%);
font-family: "Cascadia Code", Consolas, monospace;
}
code {
padding: 0.12em 0.4em;
}
kbd {
padding: 2px 6px;
box-shadow: 0 2px 0 var(--line);
font-size: 0.78rem;
}
.callout {
margin-top: 26px;
padding: 18px 20px;
border-left: 3px solid var(--cyan);
border-radius: 0 12px 12px 0;
color: var(--muted);
background: rgb(88 199 232 / 7%);
line-height: 1.7;
}
.callout--important {
border-color: var(--amber);
background: rgb(255 197 110 / 7%);
}
.callout strong {
color: var(--text);
}
.limit-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
.limit-grid div {
padding: 18px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgb(255 255 255 / 2%);
}
.limit-grid strong,
.limit-grid span {
display: block;
}
.limit-grid strong {
color: var(--mint);
font-size: 1.2rem;
}
.limit-grid span {
margin-top: 5px;
color: var(--muted);
font-size: 0.78rem;
}
.guide-table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 14px;
}
.guide-table-wrap table {
width: 100%;
border-collapse: collapse;
color: #c8d9d3;
font-size: 0.88rem;
}
.guide-table-wrap th,
.guide-table-wrap td {
padding: 14px 16px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.guide-table-wrap th {
color: var(--text);
background: rgb(255 255 255 / 4%);
}
.guide-table-wrap tr:last-child td {
border-bottom: 0;
}
.result-list {
display: grid;
gap: 10px;
margin: 0;
}
.result-list div {
display: grid;
grid-template-columns: minmax(150px, 0.4fr) minmax(0, 1fr);
gap: 18px;
padding: 14px 0;
border-bottom: 1px solid var(--line);
}
.result-list dt {
color: var(--mint);
font-family: "Cascadia Code", Consolas, monospace;
font-size: 0.86rem;
}
.result-list dd {
margin: 0;
color: var(--muted);
line-height: 1.6;
}
details {
padding: 16px 0;
border-bottom: 1px solid var(--line);
}
summary {
cursor: pointer;
font-weight: 700;
}
details p {
margin: 12px 0 0;
color: var(--muted);
line-height: 1.7;
}
.reference-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
padding-top: 48px;
border-top: 1px solid var(--line);
}
.reference-grid a {
padding: 20px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface);
text-decoration: none;
}
.reference-grid a:hover,
.reference-grid a:focus-visible {
border-color: var(--mint);
outline: none;
}
.reference-grid strong,
.reference-grid span {
display: block;
}
.reference-grid span {
margin-top: 8px;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.5;
}
@media (max-width: 720px) {
.guide-header nav {
display: none;
}
.guide-section {
grid-template-columns: 1fr;
}
.limit-grid,
.reference-grid {
grid-template-columns: 1fr;
}
.result-list div {
grid-template-columns: 1fr;
gap: 6px;
}
}
+43 -4
View File
@@ -25,6 +25,26 @@
</div>
</section>
<section class="workflow-card" aria-labelledby="workflow-title">
<div class="workflow-card__heading">
<div>
<p class="eyebrow">Local workflow</p>
<h2 id="workflow-title">파일이 흐르는 방식</h2>
</div>
<a href="/docs/#asset-reconnect">왜 원본을 다시 선택하나요?</a>
</div>
<ol class="workflow-list">
<li><strong>Dataset Builder</strong><span>음원과 위치 만들기</span></li>
<li><strong>Scene JSON + 원본 assets</strong><span>같이 보관하기</span></li>
<li><strong>Acoustic Camera</strong><span>배열·DAS 실행하기</span></li>
<li><strong>Result JSON</strong><span>map·지표 저장하기</span></li>
</ol>
<p class="workflow-note">
원본 파일은 JSON에 포함되거나 외부로 업로드되지 않습니다. Camera에서 같은 파일을 다시
선택하면 SHA-256으로 확인한 뒤 연결합니다.
</p>
</section>
<section class="launcher-grid" aria-label="도구 선택">
<a class="launcher-card" href="/apps/dataset-builder/">
<span class="launcher-card__number">01</span>
@@ -32,7 +52,7 @@
<h2>Dataset Builder</h2>
<p>기기 이미지에 음원을 배치하고 ambient를 혼합해 재현 가능한 장면을 만듭니다.</p>
</div>
<span class="launcher-card__action">장면 만들기 →</span>
<span class="launcher-card__action">새 Scene 만들기 →</span>
</a>
<a class="launcher-card launcher-card--accent" href="/apps/acoustic-camera/">
@@ -41,14 +61,33 @@
<h2>Acoustic Camera</h2>
<p>배열 응답을 계산하고 주파수 대역별 DAS 결과를 ground truth와 비교합니다.</p>
</div>
<span class="launcher-card__action">시뮬레이션 열기 →</span>
<span class="launcher-card__action">기본 예제로 바로 실행 →</span>
</a>
</section>
<section class="quick-start-grid" aria-label="빠른 시작">
<article class="quick-start-card">
<p class="eyebrow">30초 확인</p>
<h2>설정 없이 첫 결과 만들기</h2>
<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>Builder부터 끝까지</h2>
<p>Scene을 저장한 뒤 Camera에서 <strong>Scene → Array → Experiment → Assets</strong> 순서로 가져오면 참조 오류를 줄일 수 있습니다.</p>
<a href="/docs/#full-workflow">5분 안내 보기 →</a>
</article>
</section>
<footer class="landing-footer">
<a href="/docs/execution-plan.md">실행 계획</a>
<a href="/docs/">사용 설명서</a>
<span aria-hidden="true">·</span>
<a href="https://github.com/" rel="noreferrer">프로젝트는 로컬에서만 실행됩니다</a>
<a href="/tests/">테스트</a>
<span aria-hidden="true">·</span>
<a href="/benchmarks/webgpu-scaling.html">성능 측정</a>
<span aria-hidden="true">·</span>
<a href="/docs/execution-plan.md">실행 계획</a>
</footer>
</main>
<script type="module" src="/packages/ui/landing.js"></script>
+22 -1
View File
@@ -1,3 +1,24 @@
# Acoustics Core
두 앱이 공유하는 좌표계, 자유음장 전파, STFT, CPU 기준 DAS 및 평가 로직을 둡니다.
두 앱이 공유하는 무의존성 수치 기준 구현입니다.
- 결정적 tone/white/pink noise 및 오디오 asset resampling
- 자유음장 점 음원의 `delay = r / c`, `amplitude = 1 / r` 구면 전파
- zero-padded FFT phase shift 기반 fractional sample delay
- radix-2 FFT, Hann STFT와 선택 bin 추출
- scan grid 거리 cache 및 conventional frequency-domain DAS
FFT는 `X[k] = Σ x[n] exp(-j2πkn/N)` 규약을 사용합니다. 마이크 신호의 전파 phase
`exp(-j2πfr/c)`를 정렬하기 위해 DAS steering은 `exp(+j2πfr/c)`를 곱합니다. 단일 tone의
ground-truth 위치에서 peak가 생기는 회귀 테스트로 이 부호를 고정합니다.
마이크 `sensitivityDb`와 `phaseOffsetDeg`는 수신 channel에 편차로 적용하고, DAS에서는 알려진
calibration 값의 역 gain/phase로 보정합니다. raw power map과 표시용 normalized map은 서로 다른
buffer로 보존합니다. bin spectrum은 제한된 batch로 처리하며 point batch 사이에서 event loop를
양보해 진행 callback과 `AbortSignal` 취소가 동작합니다.
현재 전파 모델은 자유음장·무지향성 점 음원이며 잔향은 포함하지 않습니다. 전체 signal을
메모리에 보관하는 CPU 기준 구현이므로 source/channel과 delay FFT working set을 512 MiB로 제한합니다.
장시간 입력은 이후 chunk/STFT streaming backend에서 지원합니다.
192채널·50×50 fixture는 브라우저 통합 테스트에서 실제 합성부터 DAS peak 검출까지 실행합니다.
+274
View File
@@ -0,0 +1,274 @@
import { dbToLinear, createScanPoints, distance3, findPeak, scanIndexToPosition, TAU } from "./math.js";
import { binsForBand, extractStftBins, getFrameCount } from "./stft.js";
function throwIfAborted(signal) {
if (signal?.aborted) {
const error = new Error("계산이 취소되었습니다.");
error.name = "AbortError";
throw error;
}
}
function yieldToEventLoop() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
export function createDistanceMatrix(arrayDefinition, scan) {
const points = createScanPoints(scan);
const microphoneCount = arrayDefinition.microphones.length;
const pointCount = scan.columns * scan.rows;
const distances = new Float32Array(pointCount * microphoneCount);
for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
const pointOffset = pointIndex * 4;
const point = [points[pointOffset], points[pointOffset + 1], points[pointOffset + 2]];
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
distances[pointIndex * microphoneCount + microphoneIndex] = distance3(
point,
arrayDefinition.microphones[microphoneIndex].position,
);
}
}
return distances;
}
export async function computeBinMap(options) {
const {
bin,
spectrum,
frameCount,
arrayDefinition,
pointCount,
distances,
sampleRate,
fftSize,
speedOfSound,
signal,
pointBatchSize,
} = options;
const microphoneCount = arrayDefinition.microphones.length;
const output = new Float32Array(pointCount);
const steeringReal = new Float64Array(microphoneCount);
const steeringImaginary = new Float64Array(microphoneCount);
const angularWaveNumber = (TAU * bin * sampleRate) / (fftSize * speedOfSound);
const microphoneScale = 1 / microphoneCount;
for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
throwIfAborted(signal);
const distanceOffset = pointIndex * microphoneCount;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const microphone = arrayDefinition.microphones[microphoneIndex];
const calibrationPhase = ((microphone.phaseOffsetDeg ?? 0) * Math.PI) / 180;
const phase = angularWaveNumber * distances[distanceOffset + microphoneIndex] - calibrationPhase;
const calibrationGain = dbToLinear(-(microphone.sensitivityDb ?? 0));
steeringReal[microphoneIndex] = Math.cos(phase) * calibrationGain;
steeringImaginary[microphoneIndex] = Math.sin(phase) * calibrationGain;
}
let accumulatedPower = 0;
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const spectrumOffset = frameIndex * microphoneCount;
let sumReal = 0;
let sumImaginary = 0;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const spectrumIndex = spectrumOffset + microphoneIndex;
const inputReal = spectrum.real[spectrumIndex];
const inputImaginary = spectrum.imaginary[spectrumIndex];
const phaseReal = steeringReal[microphoneIndex];
const phaseImaginary = steeringImaginary[microphoneIndex];
sumReal += inputReal * phaseReal - inputImaginary * phaseImaginary;
sumImaginary += inputReal * phaseImaginary + inputImaginary * phaseReal;
}
accumulatedPower += (sumReal * sumReal + sumImaginary * sumImaginary) * microphoneScale * microphoneScale;
}
output[pointIndex] = accumulatedPower / frameCount;
if ((pointIndex + 1) % pointBatchSize === 0) await yieldToEventLoop();
}
return output;
}
export function estimateDasWorkload(options) {
const { channels, sampleRate, arrayDefinition, experiment } = options;
const { fftSize, hopSize } = experiment.stft;
const bandBins = experiment.frequencyBands.map((band) => binsForBand(band, sampleRate, fftSize));
const selectedBins = [...new Set(bandBins.flat())].sort((a, b) => a - b);
const frameCount = getFrameCount(channels[0].length, fftSize, hopSize);
const microphoneCount = arrayDefinition.microphones.length;
const pointCount = experiment.scan.columns * experiment.scan.rows;
const bandCount = experiment.frequencyBands.length;
const distanceBytes = pointCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT;
const outputBytes = bandCount * pointCount * Float32Array.BYTES_PER_ELEMENT * 2;
const binMapBytes = pointCount * Float32Array.BYTES_PER_ELEMENT;
const spectrumBytesPerBin = frameCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2;
const fftWorkspaceBytes = fftSize * Float64Array.BYTES_PER_ELEMENT * 2;
const baseWorkingBytes = distanceBytes + outputBytes + binMapBytes + fftWorkspaceBytes;
const complexMacCount = pointCount * microphoneCount * frameCount * selectedBins.length;
return {
bandBins,
selectedBins,
frameCount,
microphoneCount,
pointCount,
distanceBytes,
outputBytes,
spectrumBytesPerBin,
baseWorkingBytes,
complexMacCount,
};
}
export function finalizeDasBands(rawMaps, workload, experiment) {
return experiment.frequencyBands.map((band, bandIndex) => {
const rawMap = rawMaps[bandIndex];
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
rawMap[pointIndex] /= workload.bandBins[bandIndex].length;
}
const rawPeak = findPeak(rawMap);
const displayMap = Float32Array.from(rawMap);
let displayScale = "power";
if (experiment.beamforming.normalization === "peak" && rawPeak.value > 0) {
displayScale = "normalized";
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
displayMap[pointIndex] /= rawPeak.value;
}
}
return {
id: band.id,
color: band.color,
bins: workload.bandBins[bandIndex],
rawMap,
displayMap,
displayScale,
peak: {
index: rawPeak.index,
position: scanIndexToPosition(rawPeak.index, experiment.scan),
rawPower: rawPeak.value,
},
};
});
}
export async function computeDasMap(options) {
const {
channels,
sampleRate,
arrayDefinition,
scene,
experiment,
signal,
onProgress,
maximumWorkingBytes = 512 * 1024 * 1024,
maximumComplexMacs = 1_000_000_000,
maximumFftButterflies = 1_000_000_000,
maximumSpectrumBatchBins = Number.MAX_SAFE_INTEGER,
pointBatchSize = 64,
stftTransformBatchSize = 32,
} = options;
if (channels.length !== arrayDefinition.microphones.length) {
throw new Error(`채널 수 ${channels.length}와 마이크 수 ${arrayDefinition.microphones.length}가 다릅니다.`);
}
if (!Number.isInteger(pointBatchSize) || pointBatchSize < 1) throw new Error("pointBatchSize는 1 이상의 정수여야 합니다.");
const workload = estimateDasWorkload({ channels, sampleRate, arrayDefinition, experiment });
if (workload.complexMacCount > maximumComplexMacs) {
throw new Error(
`CPU DAS 예상 복소 누산 ${workload.complexMacCount.toLocaleString()}회가 ` +
`현재 한도 ${maximumComplexMacs.toLocaleString()}회를 넘습니다. grid, duration 또는 대역폭을 줄이세요.`,
);
}
const availableSpectrumBytes = maximumWorkingBytes - workload.baseWorkingBytes;
const batchCapacity = Math.floor(availableSpectrumBytes / workload.spectrumBytesPerBin);
if (batchCapacity < 1) {
throw new Error(
`CPU DAS 기본 working set ${(workload.baseWorkingBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
`현재 한도 ${(maximumWorkingBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다.`,
);
}
const batchSize = Math.max(
1,
Math.min(workload.selectedBins.length, batchCapacity, Math.max(1, maximumSpectrumBatchBins)),
);
const spectrumBatchCount = Math.ceil(workload.selectedBins.length / batchSize);
const fftButterflyCount =
spectrumBatchCount *
workload.frameCount *
workload.microphoneCount *
(experiment.stft.fftSize / 2) *
Math.log2(experiment.stft.fftSize);
if (fftButterflyCount > maximumFftButterflies) {
throw new Error(
`CPU STFT 예상 butterfly ${Math.ceil(fftButterflyCount).toLocaleString()}회가 ` +
`현재 한도 ${maximumFftButterflies.toLocaleString()}회를 넘습니다. duration, FFT 크기 또는 대역폭을 줄이세요.`,
);
}
const distances = createDistanceMatrix(arrayDefinition, experiment.scan);
const rawMaps = experiment.frequencyBands.map(() => new Float32Array(workload.pointCount));
const bandIndexesByBin = new Map();
workload.bandBins.forEach((bins, bandIndex) => {
bins.forEach((bin) => {
if (!bandIndexesByBin.has(bin)) bandIndexesByBin.set(bin, []);
bandIndexesByBin.get(bin).push(bandIndex);
});
});
let completedBins = 0;
for (let batchStart = 0; batchStart < workload.selectedBins.length; batchStart += batchSize) {
throwIfAborted(signal);
await yieldToEventLoop();
throwIfAborted(signal);
const batchBins = workload.selectedBins.slice(batchStart, batchStart + batchSize);
const stft = await extractStftBins(channels, {
fftSize: experiment.stft.fftSize,
hopSize: experiment.stft.hopSize,
bins: batchBins,
signal,
transformBatchSize: stftTransformBatchSize,
onProgress: ({ completedTransforms, totalTransforms }) =>
onProgress?.({
stage: "stft",
completedBins,
totalBins: workload.selectedBins.length,
completedTransforms,
totalTransforms,
spectrumBatch: Math.floor(batchStart / batchSize) + 1,
spectrumBatchCount,
}),
});
await yieldToEventLoop();
throwIfAborted(signal);
for (const bin of batchBins) {
const binMap = await computeBinMap({
bin,
spectrum: stft.spectra.get(bin),
frameCount: stft.frameCount,
arrayDefinition,
pointCount: workload.pointCount,
distances,
sampleRate,
fftSize: experiment.stft.fftSize,
speedOfSound: scene.speedOfSound,
signal,
pointBatchSize,
});
for (const bandIndex of bandIndexesByBin.get(bin)) {
const rawMap = rawMaps[bandIndex];
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
rawMap[pointIndex] += binMap[pointIndex];
}
}
completedBins += 1;
onProgress?.({ stage: "beamforming", completedBins, totalBins: workload.selectedBins.length });
}
}
const bands = finalizeDasBands(rawMaps, workload, experiment);
return {
backend: "cpu",
frameCount: workload.frameCount,
microphoneCount: workload.microphoneCount,
pointCount: workload.pointCount,
workload,
fftButterflyCount,
bands,
};
}
+546
View File
@@ -0,0 +1,546 @@
import { scanIndexToPosition } from "./math.js";
export const RESULT_METRIC_KEYS = Object.freeze([
"peakToSidelobeDb",
"energyInsideTruthRatio",
"precision",
"recall",
"mainLobeWidthM",
]);
const RESULT_METRIC_KEY_SET = new Set(RESULT_METRIC_KEYS);
const DEFAULT_MAXIMUM_DETECTIONS = 128;
const DEFAULT_DYNAMIC_RANGE_DB = 120;
function requireFinite(name, value) {
if (!Number.isFinite(value)) throw new RangeError(`${name} must be finite.`);
return value;
}
function requireNonNegative(name, value) {
requireFinite(name, value);
if (value < 0) throw new RangeError(`${name} must not be negative.`);
return value;
}
function requireRatio(name, value, { positive = false } = {}) {
requireFinite(name, value);
if (value < (positive ? Number.EPSILON : 0) || value > 1) {
throw new RangeError(`${name} must be ${positive ? "greater than zero and " : ""}at most one.`);
}
return value;
}
function validateScan(scan) {
if (!scan || typeof scan !== "object") throw new TypeError("scan is required.");
for (const key of ["xMin", "xMax", "yMin", "yMax", "z"]) requireFinite(`scan.${key}`, scan[key]);
for (const key of ["columns", "rows"]) {
if (!Number.isInteger(scan[key]) || scan[key] < 1) {
throw new RangeError(`scan.${key} must be a positive integer.`);
}
}
if (scan.xMin > scan.xMax || scan.yMin > scan.yMax) {
throw new RangeError("scan minimum bounds must not exceed maximum bounds.");
}
}
function normalizeRawMap(rawMap, scan) {
const descriptor = rawMap && !Array.isArray(rawMap) && !ArrayBuffer.isView(rawMap) ? rawMap : null;
const values = descriptor?.values ?? rawMap;
if (!Array.isArray(values) && !ArrayBuffer.isView(values)) {
throw new TypeError("rawMap must be an Array, a typed array, or a map descriptor with values.");
}
if (descriptor?.scale !== undefined && descriptor.scale !== "power") {
throw new RangeError("rawMap scale must be power.");
}
if (descriptor?.rows !== undefined && descriptor.rows !== scan.rows) {
throw new RangeError("rawMap rows do not match scan.rows.");
}
if (descriptor?.columns !== undefined && descriptor.columns !== scan.columns) {
throw new RangeError("rawMap columns do not match scan.columns.");
}
const expectedLength = scan.rows * scan.columns;
if (values.length !== expectedLength) {
throw new RangeError(`rawMap length must be scan.rows × scan.columns (${expectedLength}).`);
}
for (let index = 0; index < values.length; index += 1) {
requireNonNegative(`rawMap[${index}]`, values[index]);
}
return values;
}
function normalizeSources(sources) {
if (!Array.isArray(sources)) throw new TypeError("sources must be an array.");
const ids = new Set();
return sources.map((source, index) => {
if (!source || typeof source !== "object") throw new TypeError(`sources[${index}] must be an object.`);
if (typeof source.id !== "string" || source.id.length === 0) {
throw new TypeError(`sources[${index}].id must be a non-empty string.`);
}
if (ids.has(source.id)) throw new RangeError(`duplicate source id: ${source.id}`);
ids.add(source.id);
if (!Array.isArray(source.position) || source.position.length !== 3) {
throw new TypeError(`sources[${index}].position must contain three coordinates.`);
}
source.position.forEach((coordinate, coordinateIndex) => {
requireFinite(`sources[${index}].position[${coordinateIndex}]`, coordinate);
});
return source;
});
}
function gridResolution(scan) {
const x = scan.columns > 1 ? Math.abs(scan.xMax - scan.xMin) / (scan.columns - 1) : 0;
const y = scan.rows > 1 ? Math.abs(scan.yMax - scan.yMin) / (scan.rows - 1) : 0;
return { x, y, diagonal: Math.hypot(x, y) };
}
function planarDistance(a, b) {
return Math.hypot(a[0] - b[0], a[1] - b[1]);
}
function comparePeaks(a, b) {
return b.value - a.value || a.index - b.index;
}
/**
* Return positive local maxima with each equal-valued 8-connected plateau
* represented by its lowest row-major index. A positive flat map falls back
* to the deterministic global peak at index zero.
*/
export function findLocalPeaks(rawMap, scan) {
validateScan(scan);
const values = normalizeRawMap(rawMap, scan);
let globalIndex = -1;
let globalValue = 0;
for (let index = 0; index < values.length; index += 1) {
if (values[index] > globalValue) {
globalValue = values[index];
globalIndex = index;
}
}
if (!(globalValue > 0)) return [];
const visited = new Uint8Array(values.length);
const queue = new Int32Array(values.length);
const peaks = [];
for (let startIndex = 0; startIndex < values.length; startIndex += 1) {
if (visited[startIndex] || !(values[startIndex] > 0)) continue;
const plateauValue = values[startIndex];
let queueStart = 0;
let queueEnd = 1;
let representativeIndex = startIndex;
let hasHigherNeighbor = false;
let hasLowerNeighbor = false;
queue[0] = startIndex;
visited[startIndex] = 1;
while (queueStart < queueEnd) {
const index = queue[queueStart];
queueStart += 1;
representativeIndex = Math.min(representativeIndex, index);
const row = Math.floor(index / scan.columns);
const column = index % scan.columns;
const rowStart = Math.max(0, row - 1);
const rowEnd = Math.min(scan.rows - 1, row + 1);
const columnStart = Math.max(0, column - 1);
const columnEnd = Math.min(scan.columns - 1, column + 1);
for (let neighborRow = rowStart; neighborRow <= rowEnd; neighborRow += 1) {
for (let neighborColumn = columnStart; neighborColumn <= columnEnd; neighborColumn += 1) {
if (neighborRow === row && neighborColumn === column) continue;
const neighborIndex = neighborRow * scan.columns + neighborColumn;
const neighborValue = values[neighborIndex];
if (neighborValue === plateauValue) {
if (!visited[neighborIndex]) {
visited[neighborIndex] = 1;
queue[queueEnd] = neighborIndex;
queueEnd += 1;
}
} else if (neighborValue > plateauValue) {
hasHigherNeighbor = true;
} else {
hasLowerNeighbor = true;
}
}
}
}
if (!hasHigherNeighbor && hasLowerNeighbor) {
peaks.push({
index: representativeIndex,
position: scanIndexToPosition(representativeIndex, scan),
value: plateauValue,
});
}
}
if (!peaks.some(({ index }) => index === globalIndex)) {
peaks.push({ index: globalIndex, position: scanIndexToPosition(globalIndex, scan), value: globalValue });
}
peaks.sort(comparePeaks);
return peaks;
}
function selectDetections(localPeaks, thresholdValue, minimumPeakDistanceM, maximumDetections) {
const detections = [];
for (const peak of localPeaks) {
if (peak.value < thresholdValue) continue;
if (
minimumPeakDistanceM > 0 &&
detections.some((selected) => planarDistance(selected.position, peak.position) <= minimumPeakDistanceM)
) {
continue;
}
detections.push({ ...peak });
if (detections.length === maximumDetections) break;
}
return detections;
}
function matchTruths(truths, detections, matchToleranceM) {
const truthToDetection = new Int32Array(truths.length);
truthToDetection.fill(-1);
// First maximize valid tolerance-based matches. Stronger detections are
// considered first; each one's candidate truths are ordered by distance.
const adjacency = detections.map((detection) =>
truths
.map((truth, truthIndex) => ({
truthIndex,
distance: planarDistance(detection.position, truth.position),
}))
.filter(({ distance }) => distance <= matchToleranceM)
.sort((a, b) => a.distance - b.distance || a.truthIndex - b.truthIndex),
);
function assign(detectionIndex, visitedTruths) {
for (const { truthIndex } of adjacency[detectionIndex]) {
if (visitedTruths[truthIndex]) continue;
visitedTruths[truthIndex] = 1;
const previousDetection = truthToDetection[truthIndex];
if (previousDetection === -1 || assign(previousDetection, visitedTruths)) {
truthToDetection[truthIndex] = detectionIndex;
return true;
}
}
return false;
}
detections.forEach((_, detectionIndex) => assign(detectionIndex, new Uint8Array(truths.length)));
const usedDetections = new Uint8Array(detections.length);
truthToDetection.forEach((detectionIndex) => {
if (detectionIndex >= 0) usedDetections[detectionIndex] = 1;
});
// Preserve a nearest-peak localization measurement for still-unmatched
// truths without counting a distant assignment as a true positive.
const remainingPairs = [];
truths.forEach((truth, truthIndex) => {
if (truthToDetection[truthIndex] >= 0) return;
detections.forEach((detection, detectionIndex) => {
if (!usedDetections[detectionIndex]) {
remainingPairs.push({
truthIndex,
detectionIndex,
distance: planarDistance(detection.position, truth.position),
});
}
});
});
remainingPairs.sort(
(a, b) => a.distance - b.distance || a.truthIndex - b.truthIndex || a.detectionIndex - b.detectionIndex,
);
for (const pair of remainingPairs) {
if (truthToDetection[pair.truthIndex] >= 0 || usedDetections[pair.detectionIndex]) continue;
truthToDetection[pair.truthIndex] = pair.detectionIndex;
usedDetections[pair.detectionIndex] = 1;
}
const sourceMatches = truths.map((truth, truthIndex) => {
const detectionIndex = truthToDetection[truthIndex];
if (detectionIndex < 0) {
return {
sourceId: truth.sourceId,
truthPosition: [...truth.position],
peak: null,
localizationErrorM: null,
withinTolerance: false,
};
}
const detection = detections[detectionIndex];
const localizationErrorM = planarDistance(detection.position, truth.position);
return {
sourceId: truth.sourceId,
truthPosition: [...truth.position],
peak: { ...detection, position: [...detection.position] },
localizationErrorM,
withinTolerance: localizationErrorM <= matchToleranceM,
};
});
return {
sourceMatches,
truePositiveCount: sourceMatches.filter(({ withinTolerance }) => withinTolerance).length,
};
}
function energyInsideRatio(values, scan, truths, radiusM, globalValue) {
if (!(globalValue > 0) || truths.length === 0) return 0;
let total = 0;
let inside = 0;
for (let index = 0; index < values.length; index += 1) {
const scaledValue = values[index] / globalValue;
total += scaledValue;
if (truths.some((truth) => planarDistance(scanIndexToPosition(index, scan), truth.position) <= radiusM)) {
inside += scaledValue;
}
}
return total > 0 ? Math.min(1, Math.max(0, inside / total)) : 0;
}
function peakToSidelobeDb(values, scan, truths, globalPeak, exclusionRadiusM, dynamicRangeDb) {
if (!globalPeak) return 0;
const exclusionCenters = truths.length > 0 ? truths.map(({ position }) => position) : [globalPeak.position];
let mainPeak = truths.length > 0 ? 0 : globalPeak.value;
let sidelobePeak = 0;
for (let index = 0; index < values.length; index += 1) {
const position = scanIndexToPosition(index, scan);
const isMainRegion = exclusionCenters.some((center) => planarDistance(position, center) <= exclusionRadiusM);
if (isMainRegion) mainPeak = Math.max(mainPeak, values[index]);
else sidelobePeak = Math.max(sidelobePeak, values[index]);
}
if (!(mainPeak > 0)) return sidelobePeak > 0 ? -dynamicRangeDb : 0;
if (!(sidelobePeak > 0)) return dynamicRangeDb;
return Math.max(
-dynamicRangeDb,
Math.min(dynamicRangeDb, 10 * (Math.log10(mainPeak) - Math.log10(sidelobePeak))),
);
}
/** Measure the 8-connected half-power component containing a selected peak. */
export function measureMainLobe(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 });
const peakValue = values[peakIndex];
if (!(peakValue > 0)) {
return {
peakIndex,
thresholdValue: 0,
pixelCount: 0,
widthXM: 0,
widthYM: 0,
equivalentDiameterM: 0,
};
}
const thresholdValue = peakValue * halfPowerRatio;
const visited = new Uint8Array(values.length);
const queue = new Int32Array(values.length);
let queueStart = 0;
let queueEnd = 1;
let minimumRow = Math.floor(peakIndex / scan.columns);
let maximumRow = minimumRow;
let minimumColumn = peakIndex % scan.columns;
let maximumColumn = minimumColumn;
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;
minimumRow = Math.min(minimumRow, row);
maximumRow = Math.max(maximumRow, row);
minimumColumn = Math.min(minimumColumn, column);
maximumColumn = Math.max(maximumColumn, column);
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;
}
}
}
}
const resolution = gridResolution(scan);
const widthXM = scan.columns > 1 ? (maximumColumn - minimumColumn + 1) * resolution.x : 0;
const widthYM = scan.rows > 1 ? (maximumRow - minimumRow + 1) * resolution.y : 0;
let equivalentDiameterM = 0;
if (resolution.x > 0 && resolution.y > 0) {
equivalentDiameterM = 2 * Math.sqrt((queueEnd * resolution.x * resolution.y) / Math.PI);
} else {
equivalentDiameterM = Math.max(widthXM, widthYM);
}
return { peakIndex, thresholdValue, pixelCount: queueEnd, widthXM, widthYM, equivalentDiameterM };
}
/**
* Evaluate one linear-power DAS band. Source z coordinates are projected onto
* scan.z, matching the image-plane ground-truth convention.
*/
export function evaluateDasBand(options) {
if (!options || typeof options !== "object") throw new TypeError("evaluation options are required.");
const { rawMap, scan } = options;
validateScan(scan);
const values = normalizeRawMap(rawMap, scan);
const sources = normalizeSources(options.sources ?? options.scene?.sources ?? []);
const truths = sources.map((source) => ({
sourceId: source.id,
position: [source.position[0], source.position[1], scan.z],
}));
const resolution = gridResolution(scan);
const defaultRadius = resolution.diagonal > 0 ? resolution.diagonal * 1.5 : 0;
const truthRadiusM = requireNonNegative("truthRadiusM", options.truthRadiusM ?? defaultRadius);
const matchToleranceM = requireNonNegative(
"matchToleranceM",
options.matchToleranceM ?? Math.max(defaultRadius, truthRadiusM),
);
const detectionThresholdRatio = requireRatio(
"detectionThresholdRatio",
options.detectionThresholdRatio ?? 0.5,
);
const minimumPeakDistanceM = requireNonNegative(
"minimumPeakDistanceM",
options.minimumPeakDistanceM ?? resolution.diagonal,
);
const sidelobeExclusionRadiusM = requireNonNegative(
"sidelobeExclusionRadiusM",
options.sidelobeExclusionRadiusM ?? truthRadiusM,
);
const halfPowerRatio = requireRatio("halfPowerRatio", options.halfPowerRatio ?? 0.5, { positive: true });
const dynamicRangeDb = requireNonNegative(
"dynamicRangeDb",
options.dynamicRangeDb ?? DEFAULT_DYNAMIC_RANGE_DB,
);
const maximumDetections = options.maximumDetections ?? DEFAULT_MAXIMUM_DETECTIONS;
if (!Number.isInteger(maximumDetections) || maximumDetections < 1 || maximumDetections > 128) {
throw new RangeError("maximumDetections must be an integer from 1 through 128.");
}
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 { 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;
const mainLobe = globalPeak ? measureMainLobe(values, scan, globalPeak.index, halfPowerRatio) : null;
return {
globalPeak,
localPeaks,
detections,
sourceMatches,
counts: {
truth: truths.length,
detected: detections.length,
truePositive: truePositiveCount,
falsePositive: detections.length - truePositiveCount,
falseNegative: truths.length - truePositiveCount,
},
parameters: {
truthRadiusM,
matchToleranceM,
detectionThresholdRatio,
detectionThresholdValue: thresholdValue,
minimumPeakDistanceM,
sidelobeExclusionRadiusM,
halfPowerRatio,
dynamicRangeDb,
maximumDetections,
},
metrics: {
peakToSidelobeDb: peakToSidelobeDb(
values,
scan,
truths,
globalPeak,
sidelobeExclusionRadiusM,
dynamicRangeDb,
),
energyInsideTruthRatio: energyInsideRatio(values, scan, truths, truthRadiusM, globalPeak?.value ?? 0),
precision,
recall,
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
},
diagnostics: {
mainLobe,
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
gridResolutionM: resolution,
},
};
}
/** Select only metric names accepted by result.schema.json. */
export function selectResultMetrics(evaluationOrMetrics, metricNames = RESULT_METRIC_KEYS) {
const metrics = evaluationOrMetrics?.metrics ?? evaluationOrMetrics;
if (!metrics || typeof metrics !== "object") throw new TypeError("evaluation metrics are required.");
if (!Array.isArray(metricNames)) throw new TypeError("metricNames must be an array.");
const selected = {};
for (const metricName of metricNames) {
if (!RESULT_METRIC_KEY_SET.has(metricName)) {
throw new RangeError(`result.schema.json does not allow metric: ${metricName}`);
}
const value = metrics[metricName];
requireFinite(`metrics.${metricName}`, value);
selected[metricName] = value;
}
return selected;
}
/** Convert an evaluation to the schema-safe `peaks` and `metrics` band fields. */
export function exportBandEvaluation(evaluation, options = {}) {
if (!evaluation || typeof evaluation !== "object") throw new TypeError("evaluation is required.");
const maximumPeaks = options.maximumPeaks ?? 128;
if (!Number.isInteger(maximumPeaks) || maximumPeaks < 0 || maximumPeaks > 128) {
throw new RangeError("maximumPeaks must be an integer from zero through 128.");
}
const matchByPeakIndex = new Map();
for (const match of evaluation.sourceMatches ?? []) {
if (match.withinTolerance && match.peak) matchByPeakIndex.set(match.peak.index, match);
}
const peaks = (evaluation.detections ?? []).slice(0, maximumPeaks).map((peak) => {
const resultPeak = { position: [...peak.position], value: peak.value };
const match = matchByPeakIndex.get(peak.index);
if (match) {
resultPeak.sourceId = match.sourceId;
resultPeak.localizationErrorM = match.localizationErrorM;
const resolution = evaluation.diagnostics?.gridResolutionM;
if (resolution?.x > 0 && resolution?.y > 0) {
resultPeak.localizationErrorPixels = Math.hypot(
(match.peak.position[0] - match.truthPosition[0]) / resolution.x,
(match.peak.position[1] - match.truthPosition[1]) / resolution.y,
);
}
}
return resultPeak;
});
return {
peaks,
metrics: selectResultMetrics(evaluation, options.metricNames ?? RESULT_METRIC_KEYS),
evaluation: {
version: "1.0.0",
truthRadiusM: evaluation.parameters.truthRadiusM,
matchToleranceM: evaluation.parameters.matchToleranceM,
detectionThresholdRatio: evaluation.parameters.detectionThresholdRatio,
detectionThresholdValue: evaluation.parameters.detectionThresholdValue,
minimumPeakDistanceM: evaluation.parameters.minimumPeakDistanceM,
sidelobeExclusionRadiusM: evaluation.parameters.sidelobeExclusionRadiusM,
halfPowerRatio: evaluation.parameters.halfPowerRatio,
dynamicRangeDb: evaluation.parameters.dynamicRangeDb,
maximumDetections: evaluation.parameters.maximumDetections,
},
};
}
+63
View File
@@ -0,0 +1,63 @@
function assertPowerOfTwo(value) {
if (!Number.isInteger(value) || value < 2 || (value & (value - 1)) !== 0) {
throw new Error(`FFT 크기는 2 이상의 2의 거듭제곱이어야 합니다: ${value}`);
}
}
export function fftInPlace(real, imaginary, inverse = false) {
const size = real.length;
if (imaginary.length !== size) throw new Error("FFT real/imaginary 길이가 다릅니다.");
assertPowerOfTwo(size);
for (let index = 1, reversed = 0; index < size; index += 1) {
let bit = size >> 1;
while (reversed & bit) {
reversed ^= bit;
bit >>= 1;
}
reversed ^= bit;
if (index < reversed) {
[real[index], real[reversed]] = [real[reversed], real[index]];
[imaginary[index], imaginary[reversed]] = [imaginary[reversed], imaginary[index]];
}
}
const direction = inverse ? 1 : -1;
for (let length = 2; length <= size; length <<= 1) {
const angle = (direction * 2 * Math.PI) / length;
const stepReal = Math.cos(angle);
const stepImaginary = Math.sin(angle);
for (let start = 0; start < size; start += length) {
let twiddleReal = 1;
let twiddleImaginary = 0;
const halfLength = length >> 1;
for (let offset = 0; offset < halfLength; offset += 1) {
const evenIndex = start + offset;
const oddIndex = evenIndex + halfLength;
const oddReal = real[oddIndex] * twiddleReal - imaginary[oddIndex] * twiddleImaginary;
const oddImaginary = real[oddIndex] * twiddleImaginary + imaginary[oddIndex] * twiddleReal;
real[oddIndex] = real[evenIndex] - oddReal;
imaginary[oddIndex] = imaginary[evenIndex] - oddImaginary;
real[evenIndex] += oddReal;
imaginary[evenIndex] += oddImaginary;
const nextTwiddleReal = twiddleReal * stepReal - twiddleImaginary * stepImaginary;
twiddleImaginary = twiddleReal * stepImaginary + twiddleImaginary * stepReal;
twiddleReal = nextTwiddleReal;
}
}
}
if (inverse) {
for (let index = 0; index < size; index += 1) {
real[index] /= size;
imaginary[index] /= size;
}
}
}
export function fftReal(samples) {
const real = Float64Array.from(samples);
const imaginary = new Float64Array(real.length);
fftInPlace(real, imaginary);
return { real, imaginary };
}
@@ -0,0 +1,53 @@
import { fftInPlace } from "./fft.js";
import { TAU } from "./math.js";
export function nextPowerOfTwo(value) {
if (!Number.isFinite(value) || value < 1) throw new Error(`유효하지 않은 FFT 길이: ${value}`);
let result = 1;
while (result < value) result *= 2;
return result;
}
export function prepareDelaySpectrum(samples, maximumDelaySamples = 0) {
const fftSize = nextPowerOfTwo(samples.length + Math.ceil(maximumDelaySamples) + 2);
const real = new Float64Array(fftSize);
const imaginary = new Float64Array(fftSize);
real.set(samples);
fftInPlace(real, imaginary);
return { real, imaginary, fftSize };
}
export function createDelayWorkspace(fftSize) {
return { real: new Float64Array(fftSize), imaginary: new Float64Array(fftSize) };
}
export function addFrequencyDomainDelay(options) {
const { output, spectrum, delaySamples, gain = 1, phaseOffsetDeg = 0, workspace } = options;
if (!(delaySamples >= 0) || !Number.isFinite(delaySamples)) throw new Error(`유효하지 않은 delay: ${delaySamples}`);
if (workspace.real.length !== spectrum.fftSize || workspace.imaginary.length !== spectrum.fftSize) {
throw new Error("fractional-delay workspace 크기가 spectrum과 다릅니다.");
}
const size = spectrum.fftSize;
const calibrationPhase = (phaseOffsetDeg * Math.PI) / 180;
for (let bin = 0; bin < size; bin += 1) {
const signedBin = bin <= size / 2 ? bin : bin - size;
const phaseSign = signedBin === 0 ? 0 : signedBin > 0 ? 1 : -1;
const phase = (-TAU * signedBin * delaySamples) / size + phaseSign * calibrationPhase;
const phaseReal = Math.cos(phase);
const phaseImaginary = Math.sin(phase);
const inputReal = spectrum.real[bin];
const inputImaginary = spectrum.imaginary[bin];
workspace.real[bin] = inputReal * phaseReal - inputImaginary * phaseImaginary;
workspace.imaginary[bin] = inputReal * phaseImaginary + inputImaginary * phaseReal;
}
// A real sequence has a real-valued Nyquist coefficient. Projecting this single self-conjugate
// bin back to the real axis preserves Hermitian symmetry for fractional delays.
workspace.imaginary[size / 2] = 0;
fftInPlace(workspace.real, workspace.imaginary, true);
const outputLength = Math.min(output.length, workspace.real.length);
for (let sampleIndex = 0; sampleIndex < outputLength; sampleIndex += 1) {
output[sampleIndex] += workspace.real[sampleIndex] * gain;
}
}
+11
View File
@@ -0,0 +1,11 @@
export * from "./das.js";
export * from "./evaluation.js";
export * from "./fft.js";
export * from "./fractional-delay.js";
export * from "./math.js";
export * from "./mixdown.js";
export * from "./noise.js";
export * from "./propagation.js";
export * from "./signal.js";
export * from "./stft.js";
export * from "./wav.js";
+62
View File
@@ -0,0 +1,62 @@
export const TAU = Math.PI * 2;
export function distance3(a, b) {
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
}
export function dbToLinear(decibels) {
return 10 ** (decibels / 20);
}
export function powerToDb(power, floorDb = -120) {
if (!(power > 0)) return floorDb;
return Math.max(floorDb, 10 * Math.log10(power));
}
export function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
export function lerp(start, end, amount) {
return start + (end - start) * amount;
}
export function createScanPoints(scan) {
const points = new Float32Array(scan.columns * scan.rows * 4);
let offset = 0;
for (let row = 0; row < scan.rows; row += 1) {
const y = scan.rows === 1 ? scan.yMax : lerp(scan.yMax, scan.yMin, row / (scan.rows - 1));
for (let column = 0; column < scan.columns; column += 1) {
const x = scan.columns === 1 ? scan.xMin : lerp(scan.xMin, scan.xMax, column / (scan.columns - 1));
points[offset] = x;
points[offset + 1] = y;
points[offset + 2] = scan.z;
points[offset + 3] = 0;
offset += 4;
}
}
return points;
}
export function scanIndexToPosition(index, scan) {
const row = Math.floor(index / scan.columns);
const column = index % scan.columns;
return [
scan.columns === 1 ? scan.xMin : lerp(scan.xMin, scan.xMax, column / (scan.columns - 1)),
scan.rows === 1 ? scan.yMax : lerp(scan.yMax, scan.yMin, row / (scan.rows - 1)),
scan.z,
];
}
export function findPeak(values) {
if (values.length === 0) throw new Error("빈 map에서는 peak를 찾을 수 없습니다.");
let peakIndex = 0;
let peakValue = values[0];
for (let index = 1; index < values.length; index += 1) {
if (values[index] > peakValue) {
peakValue = values[index];
peakIndex = index;
}
}
return { index: peakIndex, value: peakValue };
}
+64
View File
@@ -0,0 +1,64 @@
import { renderSignal } from "./signal.js";
export const MAX_MIX_WORKING_BYTES = 384 * 1024 * 1024;
export const MAX_MIX_SAMPLE_OPERATIONS = 250_000_000;
export function estimateSceneMix(scene, assetBuffers = new Map()) {
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
const entryCount = scene.sources.length + (scene.ambient?.length ?? 0);
let assetBytes = 0;
for (const asset of assetBuffers.values()) {
for (const channel of asset.channels ?? []) assetBytes += channel.byteLength ?? 0;
}
return {
sampleCount,
entryCount,
assetBytes,
workingBytes: sampleCount * Float32Array.BYTES_PER_ELEMENT * 2 + assetBytes,
sampleOperations: sampleCount * entryCount,
};
}
export function renderSceneMix(scene, assetBuffers = new Map()) {
const estimate = estimateSceneMix(scene, assetBuffers);
if (!Number.isSafeInteger(estimate.sampleCount) || estimate.sampleCount <= 0) {
throw new Error("장면 sample 수가 올바르지 않습니다.");
}
if (estimate.workingBytes > MAX_MIX_WORKING_BYTES) {
throw new Error(
`확인용 mix 작업 메모리 ${(estimate.workingBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
`${(MAX_MIX_WORKING_BYTES / 1024 ** 2).toFixed(0)} MiB 한도를 넘습니다. duration 또는 sample rate를 줄이세요.`,
);
}
if (estimate.sampleOperations > MAX_MIX_SAMPLE_OPERATIONS) {
throw new Error(
`확인용 mix 연산량 ${estimate.sampleOperations.toLocaleString()} samples가 ` +
`${MAX_MIX_SAMPLE_OPERATIONS.toLocaleString()} 한도를 넘습니다. duration 또는 음원 수를 줄이세요.`,
);
}
const { sampleCount } = estimate;
const samples = new Float32Array(sampleCount);
for (const source of scene.sources) {
const sourceSamples = renderSignal(source, scene, assetBuffers);
for (let index = 0; index < sampleCount; index += 1) samples[index] += sourceSamples[index];
}
for (const ambient of scene.ambient ?? []) {
const ambientSamples = renderSignal(ambient, scene, assetBuffers);
for (let index = 0; index < sampleCount; index += 1) samples[index] += ambientSamples[index];
}
let preNormalizationPeak = 0;
for (const value of samples) preNormalizationPeak = Math.max(preNormalizationPeak, Math.abs(value));
const appliedScale =
scene.mix?.normalization === "peak" && preNormalizationPeak > 0
? scene.mix.targetPeak / preNormalizationPeak
: 1;
let peak = 0;
let clippedSampleCount = 0;
for (let index = 0; index < samples.length; index += 1) {
samples[index] *= appliedScale;
peak = Math.max(peak, Math.abs(samples[index]));
if (Math.abs(samples[index]) > 1) clippedSampleCount += 1;
}
return { samples, sampleRate: scene.sampleRate, preNormalizationPeak, appliedScale, peak, clippedSampleCount };
}
+34
View File
@@ -0,0 +1,34 @@
function mix32(value) {
let mixed = value >>> 0;
mixed ^= mixed >>> 16;
mixed = Math.imul(mixed, 0x7feb352d);
mixed ^= mixed >>> 15;
mixed = Math.imul(mixed, 0x846ca68b);
mixed ^= mixed >>> 16;
return mixed >>> 0;
}
export function hashString(value) {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
export function whiteNoiseAt(seed, sampleIndex) {
const mixed = mix32((seed >>> 0) ^ Math.imul(sampleIndex + 1, 0x9e3779b1));
return (mixed / 0xffffffff) * 2 - 1;
}
export function pinkNoiseAt(seed, sampleIndex) {
let total = 0;
let weight = 0;
for (let octave = 0; octave < 8; octave += 1) {
const octaveWeight = 1 / (octave + 1);
total += whiteNoiseAt(seed + octave * 0x1f123bb5, sampleIndex >> octave) * octaveWeight;
weight += octaveWeight;
}
return total / weight;
}
+123
View File
@@ -0,0 +1,123 @@
import { dbToLinear, distance3 } from "./math.js";
import {
addFrequencyDomainDelay,
createDelayWorkspace,
nextPowerOfTwo,
prepareDelaySpectrum,
} from "./fractional-delay.js";
import { renderSignal } from "./signal.js";
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
return distance3(sourcePosition, receiverPosition) / speedOfSound;
}
export function sphericalAttenuation(distance, referenceDistance = 1, minimumDistance = 0.01) {
return referenceDistance / Math.max(distance, minimumDistance);
}
export function synthesizeMicrophoneSignals(options) {
const {
scene,
arrayDefinition,
assetBuffers = new Map(),
referenceDistance = 1,
minimumDistance = 0.01,
maximumBufferBytes = 512 * 1024 * 1024,
} = options;
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
const retainedBufferCount = arrayDefinition.microphones.length + scene.sources.length + 1;
let maximumDelaySamples = 0;
for (const source of scene.sources) {
for (const microphone of arrayDefinition.microphones) {
maximumDelaySamples = Math.max(
maximumDelaySamples,
(distance3(source.position, microphone.position) / scene.speedOfSound) * scene.sampleRate,
);
}
}
const delayFftSize = nextPowerOfTwo(sampleCount + Math.ceil(maximumDelaySamples) + 2);
const delayWorkingBytes = delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4;
const estimatedBufferBytes =
retainedBufferCount * sampleCount * Float32Array.BYTES_PER_ELEMENT + delayWorkingBytes;
if (estimatedBufferBytes > maximumBufferBytes) {
throw new Error(
`예상 signal buffer ${(estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
`현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`,
);
}
const channels = arrayDefinition.microphones.map(() => new Float32Array(sampleCount));
const renderedSources = scene.sources.map((source) => ({
definition: source,
samples: renderSignal(source, scene, assetBuffers),
}));
renderedSources.forEach(({ definition, samples }) => {
const distances = arrayDefinition.microphones.map((microphone) => distance3(definition.position, microphone.position));
const maximumDelaySamples = (Math.max(...distances) / scene.speedOfSound) * scene.sampleRate;
const spectrum = prepareDelaySpectrum(samples, maximumDelaySamples);
const workspace = createDelayWorkspace(spectrum.fftSize);
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
const distance = distances[microphoneIndex];
const delaySamples = (distance / scene.speedOfSound) * scene.sampleRate;
const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0);
const amplitude = sphericalAttenuation(distance, referenceDistance, minimumDistance) * sensitivity;
addFrequencyDomainDelay({
output: channels[microphoneIndex],
spectrum,
delaySamples,
gain: amplitude,
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
workspace,
});
});
});
if (Array.isArray(scene.ambient)) {
scene.ambient.forEach((ambient, ambientIndex) => {
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 spectrum = prepareDelaySpectrum(ambientSamples, 0);
addFrequencyDomainDelay({
output: channel,
spectrum,
delaySamples: 0,
gain: dbToLinear(microphone.sensitivityDb ?? 0),
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
workspace: createDelayWorkspace(spectrum.fftSize),
});
});
});
}
let preNormalizationPeak = 0;
for (const channel of channels) {
for (const value of channel) {
preNormalizationPeak = Math.max(preNormalizationPeak, Math.abs(value));
}
}
const appliedScale =
scene.mix?.normalization === "peak" && preNormalizationPeak > 0
? scene.mix.targetPeak / preNormalizationPeak
: 1;
let peak = 0;
let clippedSampleCount = 0;
for (const channel of channels) {
for (let sampleIndex = 0; sampleIndex < channel.length; sampleIndex += 1) {
channel[sampleIndex] *= appliedScale;
const magnitude = Math.abs(channel[sampleIndex]);
peak = Math.max(peak, magnitude);
if (magnitude > 1) clippedSampleCount += 1;
}
}
return {
channels,
sampleRate: scene.sampleRate,
peak,
preNormalizationPeak,
appliedScale,
clippedSampleCount,
estimatedBufferBytes,
};
}
+53
View File
@@ -0,0 +1,53 @@
import { dbToLinear, TAU } from "./math.js";
import { hashString, pinkNoiseAt, whiteNoiseAt } from "./noise.js";
function sampleAsset(signal, assetBuffers, time) {
const asset = assetBuffers.get(signal.assetId);
if (!asset) throw new Error(`오디오 asset을 찾을 수 없습니다: ${signal.assetId}`);
const channel = asset.channels[signal.channel ?? 0];
if (!channel) throw new Error(`${signal.assetId}의 channel ${signal.channel ?? 0}을 찾을 수 없습니다.`);
let sourceIndex = time * asset.sampleRate;
if (signal.loop && channel.length > 0) sourceIndex %= channel.length;
if (sourceIndex < 0 || sourceIndex >= channel.length) return 0;
const left = Math.floor(sourceIndex);
const fraction = sourceIndex - left;
const rightValue = channel[Math.min(left + 1, channel.length - 1)];
return channel[left] + (rightValue - channel[left]) * fraction;
}
export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt = 0) {
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
const output = new Float32Array(sampleCount);
const startTime = entry.startTime ?? 0;
const duration = entry.duration ?? scene.duration;
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 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;
let value;
if (signal.type === "tone") {
value = Math.sin(TAU * signal.frequency * localTime + ((signal.phaseDeg ?? 0) * Math.PI) / 180);
} else if (signal.type === "noise") {
value = signal.color === "pink" ? pinkNoiseAt(seed, localIndex) : whiteNoiseAt(seed, localIndex);
} else if (signal.type === "asset") {
value = sampleAsset(signal, assetBuffers, localTime);
} else {
throw new Error(`지원하지 않는 signal type: ${signal.type}`);
}
output[sampleIndex] = value * gain;
}
return output;
}
export function sampleLinear(buffer, sampleIndex) {
if (sampleIndex < 0 || sampleIndex >= buffer.length) return 0;
const left = Math.floor(sampleIndex);
const fraction = sampleIndex - left;
const rightValue = buffer[Math.min(left + 1, buffer.length - 1)];
return buffer[left] + (rightValue - buffer[left]) * fraction;
}
+96
View File
@@ -0,0 +1,96 @@
import { fftInPlace } from "./fft.js";
function throwIfAborted(signal) {
if (signal?.aborted) {
const error = new Error("STFT 계산이 취소되었습니다.");
error.name = "AbortError";
throw error;
}
}
function yieldToEventLoop() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
export function hannWindow(size) {
if (!Number.isInteger(size) || size < 2) throw new Error("Hann window 크기는 2 이상이어야 합니다.");
const window = new Float64Array(size);
for (let index = 0; index < size; index += 1) {
window[index] = 0.5 - 0.5 * Math.cos((2 * Math.PI * index) / (size - 1));
}
return window;
}
export function getFrameCount(sampleCount, fftSize, hopSize) {
if (!Number.isInteger(hopSize) || hopSize < 1 || hopSize > fftSize) {
throw new Error("hopSize는 1 이상 fftSize 이하여야 합니다.");
}
return Math.max(1, 1 + Math.ceil(Math.max(0, sampleCount - fftSize) / hopSize));
}
export async function extractStftBins(channels, options) {
const { fftSize, hopSize, bins, signal, onProgress, transformBatchSize = 32 } = options;
if (!Array.isArray(channels) || channels.length === 0) throw new Error("마이크 채널이 필요합니다.");
const sampleCount = channels[0].length;
if (channels.some((channel) => channel.length !== sampleCount)) throw new Error("마이크 채널 길이가 다릅니다.");
const uniqueBins = [...new Set(bins)].sort((a, b) => a - b);
for (const bin of uniqueBins) {
if (!Number.isInteger(bin) || bin < 0 || bin > fftSize / 2) throw new Error(`유효하지 않은 FFT bin: ${bin}`);
}
const frameCount = getFrameCount(sampleCount, fftSize, hopSize);
const microphoneCount = channels.length;
if (!Number.isInteger(transformBatchSize) || transformBatchSize < 1) {
throw new Error("transformBatchSize는 1 이상의 정수여야 합니다.");
}
const spectra = new Map();
for (const bin of uniqueBins) {
spectra.set(bin, {
real: new Float32Array(frameCount * microphoneCount),
imaginary: new Float32Array(frameCount * microphoneCount),
});
}
const window = hannWindow(fftSize);
const real = new Float64Array(fftSize);
const imaginary = new Float64Array(fftSize);
const totalTransforms = frameCount * microphoneCount;
let completedTransforms = 0;
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const frameStart = frameIndex * hopSize;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const channel = channels[microphoneIndex];
real.fill(0);
imaginary.fill(0);
for (let offset = 0; offset < fftSize; offset += 1) {
real[offset] = (channel[frameStart + offset] ?? 0) * window[offset];
}
fftInPlace(real, imaginary);
const spectrumIndex = frameIndex * microphoneCount + microphoneIndex;
for (const bin of uniqueBins) {
const spectrum = spectra.get(bin);
spectrum.real[spectrumIndex] = real[bin];
spectrum.imaginary[spectrumIndex] = imaginary[bin];
}
completedTransforms += 1;
if (completedTransforms % transformBatchSize === 0) {
onProgress?.({ completedTransforms, totalTransforms });
await yieldToEventLoop();
throwIfAborted(signal);
}
}
}
onProgress?.({ completedTransforms, totalTransforms });
throwIfAborted(signal);
return { frameCount, microphoneCount, spectra };
}
export function binsForBand(band, sampleRate, fftSize) {
const binWidth = sampleRate / fftSize;
const firstBin = Math.max(0, Math.ceil(band.minHz / binWidth));
const lastBin = Math.min(fftSize / 2, Math.floor(band.maxHz / binWidth));
if (firstBin > lastBin) {
throw new Error(`${band.minHz}–${band.maxHz} Hz에 해당하는 FFT bin이 없습니다.`);
}
return Array.from({ length: lastBin - firstBin + 1 }, (_, index) => firstBin + index);
}
+43
View File
@@ -0,0 +1,43 @@
function writeAscii(view, offset, value) {
for (let index = 0; index < value.length; index += 1) view.setUint8(offset + index, value.charCodeAt(index));
}
export const MAX_PCM_WAV_BYTES = 384 * 1024 * 1024;
export function encodeMonoPcm16Wav(samples, sampleRate) {
if (!Number.isInteger(sampleRate) || sampleRate < 1 || sampleRate > 0xffffffff) {
throw new Error(`WAV sample rate가 올바르지 않습니다: ${sampleRate}`);
}
if (!samples || !Number.isSafeInteger(samples.length)) {
throw new Error("WAV samples는 길이가 있는 배열이어야 합니다.");
}
const bytesPerSample = 2;
const dataByteLength = samples.length * bytesPerSample;
if (!Number.isSafeInteger(dataByteLength) || dataByteLength > 0xffffffff - 36) {
throw new Error("PCM16 WAV가 RIFF 4 GiB 한도를 넘습니다.");
}
if (44 + dataByteLength > MAX_PCM_WAV_BYTES) {
throw new Error(`PCM16 WAV가 ${(MAX_PCM_WAV_BYTES / 1024 ** 2).toFixed(0)} MiB 메모리 한도를 넘습니다.`);
}
const buffer = new ArrayBuffer(44 + dataByteLength);
const view = new DataView(buffer);
writeAscii(view, 0, "RIFF");
view.setUint32(4, 36 + dataByteLength, true);
writeAscii(view, 8, "WAVE");
writeAscii(view, 12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * bytesPerSample, true);
view.setUint16(32, bytesPerSample, true);
view.setUint16(34, 16, true);
writeAscii(view, 36, "data");
view.setUint32(40, dataByteLength, true);
for (let index = 0; index < samples.length; index += 1) {
if (!Number.isFinite(samples[index])) throw new Error(`WAV sample ${index}가 유한한 숫자가 아닙니다.`);
const value = Math.max(-1, Math.min(1, samples[index]));
view.setInt16(44 + index * 2, value < 0 ? value * 0x8000 : value * 0x7fff, true);
}
return new Blob([buffer], { type: "audio/wav" });
}
+128
View File
@@ -0,0 +1,128 @@
export const MAX_DECODED_AUDIO_BYTES = 192 * 1024 * 1024;
const MAX_WAV_HEADER_BYTES = 1024 * 1024;
const CONSERVATIVE_COMPRESSED_CHANNELS = 32;
function ascii(view, offset, length) {
let value = "";
for (let index = 0; index < length; index += 1) value += String.fromCharCode(view.getUint8(offset + index));
return value;
}
async function readWavMetadata(file, signal) {
throwIfAborted(signal);
const header = await file.slice(0, Math.min(file.size, MAX_WAV_HEADER_BYTES)).arrayBuffer();
throwIfAborted(signal);
const view = new DataView(header);
if (view.byteLength < 12 || ascii(view, 0, 4) !== "RIFF" || ascii(view, 8, 4) !== "WAVE") {
throw new Error(`${file.name} WAV의 RIFF/WAVE header가 올바르지 않습니다.`);
}
let offset = 12;
let format = null;
let dataByteLength = null;
while (offset + 8 <= view.byteLength) {
const chunkId = ascii(view, offset, 4);
const chunkSize = view.getUint32(offset + 4, true);
const payloadOffset = offset + 8;
if (chunkId === "fmt " && chunkSize >= 16 && payloadOffset + 16 <= view.byteLength) {
format = {
channels: view.getUint16(payloadOffset + 2, true),
sampleRate: view.getUint32(payloadOffset + 4, true),
byteRate: view.getUint32(payloadOffset + 8, true),
};
} else if (chunkId === "data") {
dataByteLength = chunkSize;
break;
}
const nextOffset = payloadOffset + chunkSize + (chunkSize % 2);
if (!Number.isSafeInteger(nextOffset) || nextOffset <= offset) break;
offset = nextOffset;
}
if (!format || dataByteLength === null || format.channels < 1 || format.channels > 32 || format.byteRate < 1) {
throw new Error(`${file.name} WAV의 fmt/data chunk를 안전하게 읽지 못했습니다.`);
}
return { channels: format.channels, duration: dataByteLength / format.byteRate };
}
function throwIfAborted(signal) {
if (signal?.aborted) throw new DOMException("오디오 decode가 취소됐습니다.", "AbortError");
}
async function probeAudioDuration(file, signal) {
throwIfAborted(signal);
const url = URL.createObjectURL(file);
const audio = document.createElement("audio");
audio.preload = "metadata";
try {
return await new Promise((resolve, reject) => {
let settled = false;
const finish = (value, error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
if (error) reject(error);
else resolve(value);
};
const onAbort = () => finish(null, new DOMException("오디오 metadata 확인이 취소됐습니다.", "AbortError"));
const timeout = setTimeout(() => finish(null), 10000);
audio.addEventListener("loadedmetadata", () => finish(Number.isFinite(audio.duration) ? audio.duration : null), { once: true });
audio.addEventListener("error", () => finish(null), { once: true });
signal?.addEventListener("abort", onAbort, { once: true });
audio.src = url;
});
} finally {
audio.removeAttribute("src");
audio.load();
URL.revokeObjectURL(url);
}
}
async function estimateDecodedBytes(file, targetSampleRate, signal) {
const isWav = /(?:audio\/(?:x-)?wav)/i.test(file.type) || /\.wav$/i.test(file.name);
if (isWav) {
const metadata = await readWavMetadata(file, signal);
return Math.ceil(metadata.duration * targetSampleRate) * metadata.channels * Float32Array.BYTES_PER_ELEMENT;
}
const duration = await probeAudioDuration(file, signal);
if (duration === null) {
throw new Error(`${file.name}의 decode 크기를 미리 확인할 수 없습니다. PCM WAV로 변환해 사용하세요.`);
}
return Math.ceil(duration * targetSampleRate) * CONSERVATIVE_COMPRESSED_CHANNELS * Float32Array.BYTES_PER_ELEMENT;
}
export async function decodeAudioFile(file, targetSampleRate, maximumDecodedBytes = MAX_DECODED_AUDIO_BYTES, signal) {
if (!Number.isInteger(targetSampleRate) || targetSampleRate < 8000 || targetSampleRate > 192000) {
throw new Error(`지원하지 않는 decode sample rate입니다: ${targetSampleRate}`);
}
try {
const estimatedBytes = await estimateDecodedBytes(file, targetSampleRate, signal);
throwIfAborted(signal);
if (!Number.isSafeInteger(estimatedBytes) || estimatedBytes < 0 || estimatedBytes > maximumDecodedBytes) {
throw new Error(
`${file.name} 예상 decode 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
`${(maximumDecodedBytes / 1024 ** 2).toFixed(1)} MiB 잔여 한도를 넘습니다.`,
);
}
const context = new OfflineAudioContext(1, 1, targetSampleRate);
const fileBytes = await file.arrayBuffer();
throwIfAborted(signal);
const decoded = await context.decodeAudioData(fileBytes);
throwIfAborted(signal);
const decodedBytes = decoded.length * decoded.numberOfChannels * Float32Array.BYTES_PER_ELEMENT;
if (decodedBytes > maximumDecodedBytes) {
throw new Error(
`${file.name} decode 크기 ${(decodedBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
`${(maximumDecodedBytes / 1024 ** 2).toFixed(0)} MiB 한도를 넘습니다.`,
);
}
const channels = [];
for (let channel = 0; channel < decoded.numberOfChannels; channel += 1) {
channels.push(decoded.getChannelData(channel));
}
return { sampleRate: decoded.sampleRate, channels };
} catch (error) {
if (error?.name === "AbortError") throw error;
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`${file.name} 오디오를 ${targetSampleRate} Hz로 decode하지 못했습니다: ${reason}`);
}
}
+10
View File
@@ -0,0 +1,10 @@
# Contracts
네이티브 JSON Schema와 브라우저용 검증기를 함께 둡니다. `schema-validator.js`가 저장소의 실제
JSON Schema를 실행하고, `validation.js`는 중복 id, Nyquist, 시간 구간, 참조 무결성처럼
스키마만으로 표현하기 불편한 조건을 추가 검사합니다.
계약 버전은 `1.0.0`, 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다.
`+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서 장면 쪽입니다.
저장 의미와 canonical hash 규칙은 `docs/data-contracts.md`를 참고하세요.
+36
View File
@@ -0,0 +1,36 @@
export async function readJsonFile(file) {
try {
return JSON.parse(await file.text());
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`${file.name} 파일은 올바른 JSON이 아닙니다: ${reason}`);
}
}
export function downloadJson(value, fileName) {
const body = `${JSON.stringify(value, null, 2)}\n`;
const url = URL.createObjectURL(new Blob([body], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
export function downloadBlob(blob, fileName) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
export async function sha256File(file) {
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
}
export function deepClone(value) {
return structuredClone(value);
}
+149
View File
@@ -0,0 +1,149 @@
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function valuesEqual(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
function resolvePointer(rootSchema, reference) {
if (!reference.startsWith("#/")) throw new Error(`외부 schema ref는 지원하지 않습니다: ${reference}`);
return reference
.slice(2)
.split("/")
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce((current, key) => current?.[key], rootSchema);
}
function matchesType(value, type) {
if (type === "null") return value === null;
if (type === "array") return Array.isArray(value);
if (type === "object") return isRecord(value);
if (type === "integer") return Number.isInteger(value);
if (type === "number") return typeof value === "number" && Number.isFinite(value);
return typeof value === type;
}
function addIssue(issues, path, message) {
issues.push({ path, message });
}
function validateNode(value, schema, rootSchema, path, issues) {
if (schema === false) {
addIssue(issues, path, "허용되지 않는 값입니다.");
return;
}
if (schema === true) return;
if (schema.$ref) {
const target = resolvePointer(rootSchema, schema.$ref);
if (!target) throw new Error(`schema ref를 찾을 수 없습니다: ${schema.$ref}`);
validateNode(value, target, rootSchema, path, issues);
}
if (schema.oneOf) {
const branchResults = schema.oneOf.map((branch) => {
const branchIssues = [];
validateNode(value, branch, rootSchema, path, branchIssues);
return branchIssues;
});
if (branchResults.filter((branchIssues) => branchIssues.length === 0).length !== 1) {
addIssue(issues, path, "허용된 형식 중 정확히 하나와 일치해야 합니다.");
}
return;
}
if (Object.hasOwn(schema, "const") && !valuesEqual(value, schema.const)) {
addIssue(issues, path, `${JSON.stringify(schema.const)} 값이어야 합니다.`);
}
if (schema.enum && !schema.enum.some((candidate) => valuesEqual(value, candidate))) {
addIssue(issues, path, `허용 값: ${schema.enum.join(", ")}`);
}
const expectedTypes = schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type];
if (expectedTypes.length > 0 && !expectedTypes.some((type) => matchesType(value, type))) {
addIssue(issues, path, `${expectedTypes.join(" 또는 ")} 형식이어야 합니다.`);
return;
}
if (typeof value === "number" && Number.isFinite(value)) {
if (schema.minimum !== undefined && value < schema.minimum) addIssue(issues, path, `${schema.minimum} 이상이어야 합니다.`);
if (schema.maximum !== undefined && value > schema.maximum) addIssue(issues, path, `${schema.maximum} 이하여야 합니다.`);
if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) {
addIssue(issues, path, `${schema.exclusiveMinimum}보다 커야 합니다.`);
}
if (schema.exclusiveMaximum !== undefined && value >= schema.exclusiveMaximum) {
addIssue(issues, path, `${schema.exclusiveMaximum}보다 작아야 합니다.`);
}
}
if (typeof value === "string") {
if (schema.minLength !== undefined && value.length < schema.minLength) addIssue(issues, path, `길이가 ${schema.minLength} 이상이어야 합니다.`);
if (schema.maxLength !== undefined && value.length > schema.maxLength) addIssue(issues, path, `길이가 ${schema.maxLength} 이하여야 합니다.`);
if (schema.pattern && !new RegExp(schema.pattern, "u").test(value)) addIssue(issues, path, `형식 ${schema.pattern}과 일치해야 합니다.`);
if (schema.format === "date-time" && (!/^\d{4}-\d{2}-\d{2}T/.test(value) || !Number.isFinite(Date.parse(value)))) {
addIssue(issues, path, "유효한 ISO 8601 date-time이어야 합니다.");
}
}
if (Array.isArray(value)) {
if (schema.minItems !== undefined && value.length < schema.minItems) addIssue(issues, path, `항목이 ${schema.minItems}개 이상이어야 합니다.`);
if (schema.maxItems !== undefined && value.length > schema.maxItems) addIssue(issues, path, `항목이 ${schema.maxItems}개 이하여야 합니다.`);
if (schema.prefixItems) {
schema.prefixItems.forEach((itemSchema, index) => {
if (index < value.length) validateNode(value[index], itemSchema, rootSchema, `${path}[${index}]`, issues);
});
}
if (schema.items !== undefined) {
const startIndex = schema.prefixItems?.length ?? 0;
for (let index = startIndex; index < value.length; index += 1) {
validateNode(value[index], schema.items, rootSchema, `${path}[${index}]`, issues);
}
}
}
if (isRecord(value)) {
for (const requiredKey of schema.required ?? []) {
if (!Object.hasOwn(value, requiredKey)) addIssue(issues, `${path}.${requiredKey}`, "필수 항목입니다.");
}
const properties = schema.properties ?? {};
for (const [key, childValue] of Object.entries(value)) {
if (Object.hasOwn(properties, key)) {
validateNode(childValue, properties[key], rootSchema, `${path}.${key}`, issues);
} else if (schema.additionalProperties === false) {
addIssue(issues, `${path}.${key}`, "알 수 없는 항목입니다.");
} else if (isRecord(schema.additionalProperties)) {
validateNode(childValue, schema.additionalProperties, rootSchema, `${path}.${key}`, issues);
}
}
}
}
export function validateAgainstSchema(value, schema) {
const issues = [];
validateNode(value, schema, schema, "$", issues);
return issues;
}
const schemaPaths = {
array: "/schemas/array.schema.json",
scene: "/schemas/scene.schema.json",
experiment: "/schemas/experiment.schema.json",
result: "/schemas/result.schema.json",
};
const schemaPromises = new Map();
export async function loadSchema(name) {
if (!Object.hasOwn(schemaPaths, name)) throw new Error(`알 수 없는 schema: ${name}`);
if (!schemaPromises.has(name)) {
schemaPromises.set(
name,
fetch(schemaPaths[name]).then((response) => {
if (!response.ok) throw new Error(`${schemaPaths[name]} 로드 실패: HTTP ${response.status}`);
return response.json();
}),
);
}
return schemaPromises.get(name);
}
export async function validateDocumentStructure(name, value) {
return validateAgainstSchema(value, await loadSchema(name));
}
+439
View File
@@ -0,0 +1,439 @@
import { validateDocumentStructure } from "./schema-validator.js";
export const SCHEMA_VERSION = "1.0.0";
const MAX_MICROPHONES = 1024;
const MAX_SOURCES = 32;
const MAX_GRID_SIDE = 512;
/** @typedef {{ path: string, message: string }} ValidationIssue */
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isFiniteNumber(value) {
return typeof value === "number" && Number.isFinite(value);
}
function addIssue(issues, path, message) {
issues.push({ path, message });
}
function requireRecord(value, path, issues) {
if (!isRecord(value)) {
addIssue(issues, path, "객체여야 합니다.");
return false;
}
return true;
}
function requireString(value, path, issues) {
if (typeof value !== "string" || value.trim() === "") {
addIssue(issues, path, "비어 있지 않은 문자열이어야 합니다.");
return false;
}
return true;
}
function requireFinite(value, path, issues, options = {}) {
const { min = -Infinity, max = Infinity, exclusiveMin = false } = options;
if (!isFiniteNumber(value)) {
addIssue(issues, path, "유한한 숫자여야 합니다.");
return false;
}
if ((exclusiveMin && value <= min) || (!exclusiveMin && value < min) || value > max) {
addIssue(issues, path, `${exclusiveMin ? ">" : "≥"} ${min}, ≤ ${max} 범위여야 합니다.`);
return false;
}
return true;
}
function requireInteger(value, path, issues, options = {}) {
if (!Number.isInteger(value)) {
addIssue(issues, path, "정수여야 합니다.");
return false;
}
return requireFinite(value, path, issues, options);
}
function requirePosition(value, path, issues) {
if (!Array.isArray(value) || value.length !== 3) {
addIssue(issues, path, "[x, y, z] 숫자 3개여야 합니다.");
return false;
}
value.forEach((coordinate, index) => requireFinite(coordinate, `${path}[${index}]`, issues));
return true;
}
function validateHeader(value, expectedKind, issues) {
if (!requireRecord(value, "$", issues)) return false;
if (value.schemaVersion !== SCHEMA_VERSION) {
addIssue(issues, "$.schemaVersion", `지원 버전은 ${SCHEMA_VERSION}입니다.`);
}
if (value.kind !== expectedKind) addIssue(issues, "$.kind", `${expectedKind}이어야 합니다.`);
requireString(value.id, "$.id", issues);
requireString(value.name, "$.name", issues);
return true;
}
function findDuplicates(values) {
const seen = new Set();
const duplicates = new Set();
for (const value of values) {
if (seen.has(value)) duplicates.add(value);
seen.add(value);
}
return [...duplicates];
}
function isSafeRelativeAssetPath(value) {
if (typeof value !== "string" || value.trim() === "") return false;
const normalized = value.split(String.fromCharCode(92)).join("/");
return !normalized.startsWith("/") &&
!/^[a-z][a-z0-9+.-]*:/i.test(normalized) &&
!normalized.split("/").includes("..") &&
!normalized.includes("\0");
}
function validateArrayRelationships(value) {
/** @type {ValidationIssue[]} */
const issues = [];
if (!validateHeader(value, "micarrsim.array", issues)) return issues;
if (value.units !== "m") addIssue(issues, "$.units", "m이어야 합니다.");
if (requireRecord(value.coordinateSystem, "$.coordinateSystem", issues)) {
const expected = { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" };
for (const [key, expectedValue] of Object.entries(expected)) {
if (value.coordinateSystem[key] !== expectedValue) {
addIssue(issues, `$.coordinateSystem.${key}`, `${expectedValue}이어야 합니다.`);
}
}
}
if (requireRecord(value.aperture, "$.aperture", issues)) {
requireFinite(value.aperture.width, "$.aperture.width", issues, { min: 0, max: 20, exclusiveMin: true });
requireFinite(value.aperture.height, "$.aperture.height", issues, { min: 0, max: 20, exclusiveMin: true });
}
if (!Array.isArray(value.microphones) || value.microphones.length === 0) {
addIssue(issues, "$.microphones", "마이크가 하나 이상 필요합니다.");
return issues;
}
if (value.microphones.length > MAX_MICROPHONES) {
addIssue(issues, "$.microphones", `마이크는 최대 ${MAX_MICROPHONES}개까지 지원합니다.`);
}
const ids = [];
value.microphones.forEach((microphone, index) => {
const path = `$.microphones[${index}]`;
if (!requireRecord(microphone, path, issues)) return;
if (requireString(microphone.id, `${path}.id`, issues)) ids.push(microphone.id);
requirePosition(microphone.position, `${path}.position`, issues);
if (microphone.sensitivityDb !== undefined) {
requireFinite(microphone.sensitivityDb, `${path}.sensitivityDb`, issues, { min: -120, max: 120 });
}
});
for (const duplicate of findDuplicates(ids)) {
addIssue(issues, "$.microphones", `중복 마이크 id: ${duplicate}`);
}
return issues;
}
function validateSignal(signal, path, issues, assetsById, sampleRate) {
if (!requireRecord(signal, path, issues)) return;
if (signal.type === "tone") {
if (requireFinite(signal.frequency, `${path}.frequency`, issues, { min: 0, exclusiveMin: true })) {
if (signal.frequency >= sampleRate / 2) {
addIssue(issues, `${path}.frequency`, `Nyquist 주파수 ${sampleRate / 2} Hz보다 낮아야 합니다.`);
}
}
if (signal.phaseDeg !== undefined) {
requireFinite(signal.phaseDeg, `${path}.phaseDeg`, issues, { min: -360, max: 360 });
}
} else if (signal.type === "noise") {
if (!["white", "pink"].includes(signal.color)) {
addIssue(issues, `${path}.color`, "white 또는 pink여야 합니다.");
}
} else if (signal.type === "asset") {
if (requireString(signal.assetId, `${path}.assetId`, issues)) {
const asset = assetsById.get(signal.assetId);
if (!asset) addIssue(issues, `${path}.assetId`, `정의되지 않은 asset: ${signal.assetId}`);
else if (asset.kind !== "audio") addIssue(issues, `${path}.assetId`, "audio asset을 참조해야 합니다.");
}
} else {
addIssue(issues, `${path}.type`, "tone, noise 또는 asset이어야 합니다.");
}
}
function validateSceneRelationships(value) {
/** @type {ValidationIssue[]} */
const issues = [];
if (!validateHeader(value, "micarrsim.scene", issues)) return issues;
if (value.units !== "m") addIssue(issues, "$.units", "m이어야 합니다.");
const sampleRateValid = requireInteger(value.sampleRate, "$.sampleRate", issues, { min: 8000, max: 192000 });
const sceneDurationValid = requireFinite(value.duration, "$.duration", issues, {
min: 0,
max: 300,
exclusiveMin: true,
});
requireFinite(value.speedOfSound, "$.speedOfSound", issues, { min: 250, max: 400 });
requireInteger(value.seed, "$.seed", issues, { min: 0, max: 0xffffffff });
const assets = Array.isArray(value.assets) ? value.assets : [];
const assetsById = new Map();
const allIds = [];
assets.forEach((asset, index) => {
const path = `$.assets[${index}]`;
if (!requireRecord(asset, path, issues)) return;
if (requireString(asset.id, `${path}.id`, issues)) {
assetsById.set(asset.id, asset);
allIds.push(asset.id);
}
if (requireString(asset.fileName, `${path}.fileName`, issues) && !isSafeRelativeAssetPath(asset.fileName)) {
addIssue(issues, `${path}.fileName`, "manifest 기준의 안전한 상대 경로여야 합니다.");
}
requireString(asset.mimeType, `${path}.mimeType`, issues);
if (!["audio", "image"].includes(asset.kind)) addIssue(issues, `${path}.kind`, "audio 또는 image여야 합니다.");
});
for (const duplicate of findDuplicates(allIds)) addIssue(issues, "$.assets", `중복 asset id: ${duplicate}`);
if (value.image !== undefined && requireRecord(value.image, "$.image", issues)) {
if (requireString(value.image.assetId, "$.image.assetId", issues)) {
const asset = assetsById.get(value.image.assetId);
if (!asset) addIssue(issues, "$.image.assetId", `정의되지 않은 asset: ${value.image.assetId}`);
else if (asset.kind !== "image") addIssue(issues, "$.image.assetId", "image asset을 참조해야 합니다.");
}
if (requireRecord(value.image.plane, "$.image.plane", issues)) {
const plane = value.image.plane;
requireFinite(plane.z, "$.image.plane.z", issues, { min: 0, exclusiveMin: true });
for (const key of ["xMin", "xMax", "yMin", "yMax"]) {
requireFinite(plane[key], `$.image.plane.${key}`, issues);
}
if (isFiniteNumber(plane.xMin) && isFiniteNumber(plane.xMax) && plane.xMin >= plane.xMax) {
addIssue(issues, "$.image.plane", "xMin은 xMax보다 작아야 합니다.");
}
if (isFiniteNumber(plane.yMin) && isFiniteNumber(plane.yMax) && plane.yMin >= plane.yMax) {
addIssue(issues, "$.image.plane", "yMin은 yMax보다 작아야 합니다.");
}
}
}
if (!Array.isArray(value.sources) || value.sources.length === 0) {
addIssue(issues, "$.sources", "음원이 하나 이상 필요합니다.");
return issues;
}
if (value.sources.length > MAX_SOURCES) {
addIssue(issues, "$.sources", `음원은 최대 ${MAX_SOURCES}개까지 지원합니다.`);
}
const sourceIds = [];
value.sources.forEach((source, index) => {
const path = `$.sources[${index}]`;
if (!requireRecord(source, path, issues)) return;
if (requireString(source.id, `${path}.id`, issues)) sourceIds.push(source.id);
requireString(source.name, `${path}.name`, issues);
requirePosition(source.position, `${path}.position`, issues);
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, {
min: 0,
max: 300,
exclusiveMin: true,
});
if (sceneDurationValid && startValid && durationValid && source.startTime + source.duration > value.duration + 1e-9) {
addIssue(issues, path, "음원 구간이 장면 duration을 벗어납니다.");
}
validateSignal(source.signal, `${path}.signal`, issues, assetsById, sampleRateValid ? value.sampleRate : 48000);
});
for (const duplicate of findDuplicates(sourceIds)) addIssue(issues, "$.sources", `중복 음원 id: ${duplicate}`);
if (Array.isArray(value.ambient)) {
const ambientIds = [];
value.ambient.forEach((ambient, index) => {
const path = `$.ambient[${index}]`;
if (!requireRecord(ambient, path, issues)) return;
if (requireString(ambient.id, `${path}.id`, issues)) ambientIds.push(ambient.id);
requireFinite(ambient.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 });
validateSignal(ambient.signal, `${path}.signal`, issues, assetsById, sampleRateValid ? value.sampleRate : 48000);
});
for (const duplicate of findDuplicates(ambientIds)) addIssue(issues, "$.ambient", `중복 ambient id: ${duplicate}`);
}
return issues;
}
function validateExperimentRelationships(value, context = {}) {
/** @type {ValidationIssue[]} */
const issues = [];
if (!validateHeader(value, "micarrsim.experiment", issues)) return issues;
requireString(value.arrayId, "$.arrayId", issues);
requireString(value.sceneId, "$.sceneId", issues);
if (context.arrayDefinition && value.arrayId !== context.arrayDefinition.id) {
addIssue(issues, "$.arrayId", "불러온 배열 id와 다릅니다.");
}
if (context.scene && value.sceneId !== context.scene.id) {
addIssue(issues, "$.sceneId", "불러온 장면 id와 다릅니다.");
}
if (requireRecord(value.scan, "$.scan", issues)) {
requireFinite(value.scan.z, "$.scan.z", issues, { min: 0, exclusiveMin: true });
for (const key of ["xMin", "xMax", "yMin", "yMax"]) {
requireFinite(value.scan[key], `$.scan.${key}`, issues);
}
requireInteger(value.scan.columns, "$.scan.columns", issues, { min: 8, max: MAX_GRID_SIDE });
requireInteger(value.scan.rows, "$.scan.rows", issues, { min: 8, max: MAX_GRID_SIDE });
if (isFiniteNumber(value.scan.xMin) && isFiniteNumber(value.scan.xMax) && value.scan.xMin >= value.scan.xMax) {
addIssue(issues, "$.scan", "xMin은 xMax보다 작아야 합니다.");
}
if (isFiniteNumber(value.scan.yMin) && isFiniteNumber(value.scan.yMax) && value.scan.yMin >= value.scan.yMax) {
addIssue(issues, "$.scan", "yMin은 yMax보다 작아야 합니다.");
}
}
if (!Array.isArray(value.frequencyBands) || value.frequencyBands.length === 0) {
addIssue(issues, "$.frequencyBands", "주파수 대역이 하나 이상 필요합니다.");
} else {
const bandIds = [];
value.frequencyBands.forEach((band, index) => {
const path = `$.frequencyBands[${index}]`;
if (!requireRecord(band, path, issues)) return;
if (requireString(band.id, `${path}.id`, issues)) bandIds.push(band.id);
const minValid = requireFinite(band.minHz, `${path}.minHz`, issues, { min: 0 });
const maxValid = requireFinite(band.maxHz, `${path}.maxHz`, issues, { min: 0, exclusiveMin: true });
if (minValid && maxValid && band.minHz >= band.maxHz) {
addIssue(issues, path, "minHz는 maxHz보다 작아야 합니다.");
}
if (context.scene && maxValid && band.maxHz >= context.scene.sampleRate / 2) {
addIssue(issues, `${path}.maxHz`, `Nyquist 주파수 ${context.scene.sampleRate / 2} Hz보다 낮아야 합니다.`);
}
if (typeof band.color !== "string" || !/^#[0-9a-f]{6}$/i.test(band.color)) {
addIssue(issues, `${path}.color`, "#RRGGBB 색상이어야 합니다.");
}
});
for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.frequencyBands", `중복 band id: ${duplicate}`);
}
if (requireRecord(value.stft, "$.stft", issues)) {
if (![128, 256, 512, 1024, 2048, 4096].includes(value.stft.fftSize)) {
addIssue(issues, "$.stft.fftSize", "지원되는 2의 거듭제곱 크기가 아닙니다.");
}
if (!Number.isInteger(value.stft.hopSize) || value.stft.hopSize < 1 || value.stft.hopSize > value.stft.fftSize) {
addIssue(issues, "$.stft.hopSize", "1 이상 fftSize 이하의 정수여야 합니다.");
}
if (value.stft.window !== "hann") addIssue(issues, "$.stft.window", "hann이어야 합니다.");
}
if (requireRecord(value.beamforming, "$.beamforming", issues)) {
if (value.beamforming.algorithm !== "das") addIssue(issues, "$.beamforming.algorithm", "das이어야 합니다.");
if (!["auto", "cpu", "webgpu"].includes(value.beamforming.backend)) {
addIssue(issues, "$.beamforming.backend", "auto, cpu 또는 webgpu여야 합니다.");
}
requireFinite(value.beamforming.minimumDistance, "$.beamforming.minimumDistance", issues, {
min: 0,
max: 10,
exclusiveMin: true,
});
}
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,
);
}
}
let delayFftSize = 1;
while (delayFftSize < sampleCount + Math.ceil(maximumDelaySamples) + 2) delayFftSize *= 2;
const estimatedBytes = retainedBufferCount * sampleCount * 4 + delayFftSize * 8 * 4;
const maximumBytes = 512 * 1024 * 1024;
if (estimatedBytes > maximumBytes) {
addIssue(
issues,
"$",
`현재 비스트리밍 CPU 모델의 signal buffer 예상 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`,
);
}
}
return issues;
}
function validateResultRelationships(value, context = {}) {
/** @type {ValidationIssue[]} */
const issues = [];
if (!isRecord(value)) return issues;
if (context.experiment) {
if (value.experimentId !== context.experiment.id) addIssue(issues, "$.experimentId", "불러온 실험 id와 다릅니다.");
if (value.arrayId !== context.experiment.arrayId) addIssue(issues, "$.arrayId", "실험의 배열 id와 다릅니다.");
if (value.sceneId !== context.experiment.sceneId) addIssue(issues, "$.sceneId", "실험의 장면 id와 다릅니다.");
}
const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []);
const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []);
const bandIds = [];
if (Array.isArray(value.bands)) {
value.bands.forEach((band, bandIndex) => {
if (!isRecord(band)) return;
const path = `$.bands[${bandIndex}]`;
if (typeof band.id === "string") {
bandIds.push(band.id);
if (expectedBandIds.size > 0 && !expectedBandIds.has(band.id)) addIssue(issues, `${path}.id`, "실험에 없는 band id입니다.");
}
const maps = [band.rawMap, band.displayMap, ...(band.processedMaps?.map((item) => item.map) ?? [])].filter(Boolean);
maps.forEach((map, mapIndex) => {
if (!isRecord(map) || !Array.isArray(map.values)) return;
if (Number.isInteger(map.rows) && Number.isInteger(map.columns) && map.values.length !== map.rows * map.columns) {
addIssue(issues, `${path}.maps[${mapIndex}].values`, "values 길이는 rows × columns와 같아야 합니다.");
}
if (context.experiment && (map.rows !== context.experiment.scan.rows || map.columns !== context.experiment.scan.columns)) {
addIssue(issues, `${path}.maps[${mapIndex}]`, "map shape이 실험 scan grid와 다릅니다.");
}
});
if (isRecord(band.rawMap) && band.rawMap.scale !== "power") {
addIssue(issues, `${path}.rawMap.scale`, "rawMap은 power scale이어야 합니다.");
}
if (Array.isArray(band.peaks)) {
band.peaks.forEach((peak, peakIndex) => {
if (peak?.sourceId && sourceIds.size > 0 && !sourceIds.has(peak.sourceId)) {
addIssue(issues, `${path}.peaks[${peakIndex}].sourceId`, "장면에 없는 source id입니다.");
}
});
}
});
}
for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.bands", `중복 result band id: ${duplicate}`);
return issues;
}
async function combineValidation(name, value, relationshipIssues) {
return [...(await validateDocumentStructure(name, value)), ...relationshipIssues];
}
export async function validateArrayDefinition(value) {
return combineValidation("array", value, validateArrayRelationships(value));
}
export async function validateScene(value) {
return combineValidation("scene", value, validateSceneRelationships(value));
}
export async function validateExperiment(value, context = {}) {
return combineValidation("experiment", value, validateExperimentRelationships(value, context));
}
export async function validateResult(value, context = {}) {
return combineValidation("result", value, validateResultRelationships(value, context));
}
export async function assertValid(value, validator, label = "데이터") {
const issues = await validator(value);
if (issues.length > 0) {
const details = issues.map(({ path, message }) => `${path}: ${message}`).join("\n");
throw new Error(`${label} 검증 실패\n${details}`);
}
return value;
}
+118 -2
View File
@@ -114,11 +114,92 @@ p {
background: rgb(255 197 110 / 8%);
}
.workflow-card {
margin-top: clamp(42px, 7vw, 72px);
padding: clamp(22px, 4vw, 34px);
border: 1px solid var(--line);
border-radius: 22px;
background: rgb(255 255 255 / 2%);
}
.workflow-card__heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
}
.workflow-card__heading h2,
.quick-start-card h2 {
margin-bottom: 0;
letter-spacing: -0.035em;
}
.workflow-card__heading a,
.quick-start-card a {
color: var(--mint);
font-size: 0.84rem;
font-weight: 700;
text-decoration: none;
}
.workflow-list {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin: 24px 0 16px;
padding: 0;
list-style: none;
counter-reset: workflow;
}
.workflow-list li {
position: relative;
min-height: 104px;
padding: 18px;
border: 1px solid rgb(101 242 191 / 18%);
border-radius: 14px;
background: rgb(101 242 191 / 5%);
counter-increment: workflow;
}
.workflow-list li::before {
display: block;
margin-bottom: 18px;
color: var(--mint);
content: "0" counter(workflow);
font-size: 0.7rem;
font-weight: 760;
letter-spacing: 0.1em;
}
.workflow-list strong,
.workflow-list span {
display: block;
}
.workflow-list strong {
font-size: 0.9rem;
}
.workflow-list span {
margin-top: 6px;
color: var(--muted);
font-size: 0.78rem;
}
.workflow-note {
margin-bottom: 0;
color: var(--muted);
font-size: 0.82rem;
line-height: 1.65;
}
.launcher-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
margin-top: clamp(56px, 9vw, 104px);
margin-top: 18px;
}
.launcher-card {
@@ -181,6 +262,30 @@ p {
color: var(--cyan);
}
.quick-start-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
margin-top: 18px;
}
.quick-start-card {
padding: clamp(22px, 4vw, 32px);
border: 1px solid var(--line);
border-radius: 20px;
background: var(--surface);
}
.quick-start-card p:not(.eyebrow) {
margin: 18px 0;
color: var(--muted);
line-height: 1.7;
}
.quick-start-card strong {
color: var(--text);
}
.landing-footer {
display: flex;
gap: 10px;
@@ -259,10 +364,21 @@ p {
}
@media (max-width: 720px) {
.launcher-grid {
.launcher-grid,
.quick-start-grid,
.workflow-list {
grid-template-columns: 1fr;
}
.workflow-card__heading {
align-items: start;
flex-direction: column;
}
.workflow-list li {
min-height: auto;
}
.launcher-card {
min-height: 260px;
}
+8 -6
View File
@@ -1,12 +1,14 @@
import { detectWebGpuCapability } from "./webgpu-status.js";
const statusElement = document.querySelector("#webgpu-status");
if (!(statusElement instanceof HTMLElement)) {
throw new Error("WebGPU status element is missing.");
}
if ("gpu" in navigator) {
statusElement.textContent = "WebGPU 사용 가능";
} else {
statusElement.textContent = "CPU fallback 사용 예정";
statusElement.classList.add("status-chip--warning");
}
detectWebGpuCapability().then((capability) => {
statusElement.textContent = capability.label;
statusElement.title = capability.detail;
statusElement.classList.toggle("status-chip--warning", !capability.available);
statusElement.dataset.backendAvailable = capability.available ? "webgpu" : "cpu";
});
+12 -7
View File
@@ -1,8 +1,13 @@
const statusElements = document.querySelectorAll("[data-runtime-status]");
const hasWebGpu = "gpu" in navigator;
import { detectWebGpuCapability } from "./webgpu-status.js";
for (const statusElement of statusElements) {
statusElement.textContent = hasWebGpu ? "WebGPU 준비됨" : "CPU fallback";
statusElement.classList.toggle("status-chip--warning", !hasWebGpu);
statusElement.classList.toggle("status-chip--neutral", hasWebGpu);
}
const statusElements = document.querySelectorAll("[data-runtime-status]");
detectWebGpuCapability().then((capability) => {
for (const statusElement of statusElements) {
statusElement.textContent = capability.label;
statusElement.title = capability.detail;
statusElement.classList.toggle("status-chip--warning", !capability.available);
statusElement.classList.toggle("status-chip--neutral", capability.available);
statusElement.dataset.backendAvailable = capability.available ? "webgpu" : "cpu";
}
});
+33
View File
@@ -0,0 +1,33 @@
let capabilityPromise;
export function detectWebGpuCapability() {
capabilityPromise ??= detect();
return capabilityPromise;
}
async function detect() {
if (!navigator.gpu) {
return { available: false, label: "CPU fallback", detail: "WebGPU API 없음" };
}
try {
const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
if (!adapter) {
return { available: false, label: "CPU fallback", detail: "WebGPU adapter 없음" };
}
const info = adapter.info;
const adapterName = [info?.vendor, info?.architecture || info?.device]
.filter(Boolean)
.join(" · ");
return {
available: true,
label: adapterName ? `WebGPU · ${adapterName}` : "WebGPU adapter 준비됨",
detail: adapterName || "WebGPU adapter 준비됨",
};
} catch (error) {
return {
available: false,
label: "CPU fallback",
detail: error instanceof Error ? error.message : String(error),
};
}
}
+39 -1
View File
@@ -1,3 +1,41 @@
# WebGPU Kernels
WGSL steering vector 및 DAS 연산과 GPU buffer layout, benchmark 코드를 둡니다.
CPU STFT 결과를 받아 conventional delay-and-sum(DAS)을 WebGPU로 실행합니다. `backend: "auto"`는
WebGPU unavailable, device limit, validation/OOM, device-loss 같은 운용 오류에서만 CPU 기준 구현으로
한 번 fallback합니다. `backend: "webgpu"`는 오류를 숨기지 않으며, 취소와 잘못된 입력도 fallback하지
않습니다.
## 계산 구조
`das.wgsl.js`는 두 compute pass를 제공합니다.
1. `compute_steering`: `(bin, point, microphone)`별 보정된 complex steering vector를 한 번 계산
2. `compute_das`: 모든 STFT frame에서 steering을 재사용해 frame 평균 power 계산
평면 scan 순서, phase 부호, 감도/위상 보정, complex multiply, bin 평균과 peak normalization은 CPU
기준 구현과 동일합니다. GPU 결과를 읽은 뒤 band 조합과 정규화는 `finalizeDasBands()`를 함께
사용하므로 raw map과 display map의 의미도 같습니다.
## 장치 한도와 메모리
실행기는 하드코딩한 GPU 크기를 믿지 않고 `device.limits`를 조회합니다. steering buffer는 기본
64 MiB, 전체 활성 GPU working set은 기본 256 MiB 이하가 되도록 분할합니다. CPU FFT 재계산을
피하기 위해 가능한 bin batch를 유지하고 steering이 커지면 point를 먼저 chunk합니다. output storage와
`MAP_READ` staging buffer는 WebGPU usage 규칙에 따라 분리합니다.
장치 초기화, WGSL `getCompilationInfo()`, async pipeline 생성, validation/OOM/internal error scope,
`device.lost`, `uncapturederror`, readback 실패를 각각 처리합니다. 관련 기준은 최신
[WebGPU specification](https://www.w3.org/TR/webgpu/)과
[WGSL floating-point accuracy](https://www.w3.org/TR/WGSL/#floating-point-accuracy)를 따릅니다.
## 검증과 benchmark
- `/tests/`: CPU/GPU 교차검증 또는 WebGPU가 없는 환경의 실제 CPU fallback
- `/benchmarks/webgpu-scaling.html`: 192채널 fixture를 예열한 뒤 50²/100²/200² 각각 3회 측정하고
JSON 저장
- `docs/benchmarks/webgpu-intel-gen9-2026-08-28.json`: Intel Gen9에서 기록한 기준 실측
실측의 `gpuWallMs`는 이미 queue에 넣은 input transfer의 완료 대기 + 두 compute pass + readback
wall-clock입니다. host packing과 `writeBuffer()` 호출은 그 타이머 직전이며, `totalMs`에는 이 작업과
장치/pipeline 초기화, CPU STFT도 포함됩니다. GPU timestamp query는 필수 기능이 아니므로 지원되지
않는 장치도 동일한 계산 경로를 쓸 수 있습니다.
+58
View File
@@ -0,0 +1,58 @@
import { computeDasMap } from "../acoustics-core/das.js";
import { computeDasWebGpu } from "./das.js";
import { isRecoverableWebGpuError } from "./device.js";
function createAbortError(signal) {
if (signal?.reason instanceof Error && signal.reason.name === "AbortError") return signal.reason;
const error = new Error("DAS 계산이 취소되었습니다.");
error.name = "AbortError";
return error;
}
function throwIfAborted(signal) {
if (signal?.aborted) throw createAbortError(signal);
}
/**
* Execute the requested DAS backend. `auto` falls back only for typed WebGPU
* operational errors; invalid inputs and AbortError remain visible to callers.
*/
export async function computeDasWithBackend(options, dependencies = {}) {
const computeGpu = dependencies.computeGpu ?? computeDasWebGpu;
const computeCpu = dependencies.computeCpu ?? computeDasMap;
const requestedBackend = options?.experiment?.beamforming?.backend ?? "auto";
if (requestedBackend === "cpu") return computeCpu(options);
if (requestedBackend === "webgpu") return computeGpu(options);
if (requestedBackend !== "auto") {
throw new TypeError(`지원하지 않는 DAS backend입니다: ${requestedBackend}`);
}
try {
return await computeGpu(options);
} catch (gpuError) {
if (!isRecoverableWebGpuError(gpuError)) throw gpuError;
throwIfAborted(options.signal);
options.onFallback?.({ from: "webgpu", to: "cpu", error: gpuError });
throwIfAborted(options.signal);
try {
const cpuResult = await computeCpu(options);
return {
...cpuResult,
requestedBackend: "auto",
fallback: {
from: "webgpu",
code: gpuError.code,
message: gpuError.message,
},
};
} catch (cpuError) {
if (cpuError?.name === "AbortError") throw cpuError;
throwIfAborted(options.signal);
throw new AggregateError(
[gpuError, cpuError],
`WebGPU 실행 실패 후 CPU fallback도 실패했습니다: ${cpuError.message}`,
);
}
}
}
+656
View File
@@ -0,0 +1,656 @@
import {
createDistanceMatrix,
estimateDasWorkload,
finalizeDasBands,
} from "../acoustics-core/das.js";
import { extractStftBins } from "../acoustics-core/stft.js";
import {
DAS_PARAMS_BYTE_LENGTH,
DAS_WGSL,
WORKGROUP_SIZE,
packDasParams,
packMicrophoneCalibration,
} from "./das.wgsl.js";
import {
WebGpuError,
WebGpuExecutionError,
WebGpuLimitError,
WebGpuUnavailableError,
assertWebGpuContextHealthy,
closeWebGpuContext,
createCheckedComputePipeline,
createWebGpuContext,
withWebGpuErrorScopes,
} from "./device.js";
export {
WebGpuError,
WebGpuExecutionError,
WebGpuLimitError,
WebGpuUnavailableError,
} from "./device.js";
const MEBIBYTE = 1024 ** 2;
const DEFAULT_MAXIMUM_GPU_WORKING_BYTES = 256 * MEBIBYTE;
const DEFAULT_MAXIMUM_HOST_WORKING_BYTES = 512 * MEBIBYTE;
const DEFAULT_MAXIMUM_STEERING_BUFFER_BYTES = 64 * MEBIBYTE;
export const DEFAULT_MAXIMUM_MACS_PER_INVOCATION = 250_000;
function now() {
return globalThis.performance?.now?.() ?? Date.now();
}
function throwIfAborted(signal) {
if (!signal?.aborted) return;
const error = new Error("WebGPU DAS 계산이 취소되었습니다.");
error.name = "AbortError";
throw error;
}
function yieldToEventLoop() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function requirePositiveInteger(value, label) {
if (!Number.isSafeInteger(value) || value < 1) {
throw new WebGpuLimitError(`${label} 값이 유효하지 않습니다: ${value}`);
}
return value;
}
function limitValue(limits, name, fallback) {
return requirePositiveInteger(limits?.[name] ?? fallback, `device.limits.${name}`);
}
function minimumBufferSize(byteLength) {
return Math.max(4, Math.ceil(byteLength / 4) * 4);
}
export function packStftBatch(stft, bins) {
if (!stft || !Number.isInteger(stft.frameCount) || !Number.isInteger(stft.microphoneCount)) {
throw new TypeError("유효한 STFT 결과가 필요합니다.");
}
if (!Array.isArray(bins) || bins.length === 0) throw new TypeError("하나 이상의 FFT bin이 필요합니다.");
const valuesPerBin = stft.frameCount * stft.microphoneCount;
const packed = new Float32Array(bins.length * valuesPerBin * 2);
bins.forEach((bin, binIndex) => {
const spectrum = stft.spectra.get(bin);
if (!spectrum || spectrum.real.length !== valuesPerBin || spectrum.imaginary.length !== valuesPerBin) {
throw new Error(`FFT bin ${bin}의 spectrum layout이 올바르지 않습니다.`);
}
const binOffset = binIndex * valuesPerBin * 2;
for (let index = 0; index < valuesPerBin; index += 1) {
packed[binOffset + index * 2] = spectrum.real[index];
packed[binOffset + index * 2 + 1] = spectrum.imaginary[index];
}
});
return packed;
}
function pointCapacityForBinBatch({
workload,
binBatchSize,
storageBindingLimit,
bufferLimit,
maximumSteeringBufferBytes,
maximumGpuWorkingBytes,
maximumHostWorkingBytes,
maximumPointDispatch,
}) {
const microphoneCount = workload.microphoneCount;
const distanceBytesPerPoint = microphoneCount * Float32Array.BYTES_PER_ELEMENT;
const steeringBytesPerPoint =
binBatchSize * microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2;
const outputBytesPerPoint = binBatchSize * Float32Array.BYTES_PER_ELEMENT;
const spectrumBytes = binBatchSize * workload.spectrumBytesPerBin;
const binIndexBytes = binBatchSize * Uint32Array.BYTES_PER_ELEMENT;
const calibrationBytes = microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2;
const storageLimit = Math.min(storageBindingLimit, bufferLimit);
const steeringLimit = Math.min(storageLimit, maximumSteeringBufferBytes);
const gpuFixedBytes = DAS_PARAMS_BYTE_LENGTH + calibrationBytes + spectrumBytes + binIndexBytes;
const gpuBytesPerPoint = distanceBytesPerPoint + steeringBytesPerPoint + outputBytesPerPoint * 2;
const hostFixedBytes = workload.baseWorkingBytes + spectrumBytes * 2 + binIndexBytes;
const hostBytesPerPoint = distanceBytesPerPoint + outputBytesPerPoint;
return Math.max(
0,
Math.min(
workload.pointCount,
maximumPointDispatch,
Math.floor(storageLimit / distanceBytesPerPoint),
Math.floor(steeringLimit / steeringBytesPerPoint),
Math.floor(storageLimit / outputBytesPerPoint),
Math.floor(bufferLimit / outputBytesPerPoint),
Math.floor((maximumGpuWorkingBytes - gpuFixedBytes) / gpuBytesPerPoint),
Math.floor((maximumHostWorkingBytes - hostFixedBytes) / hostBytesPerPoint),
),
);
}
/**
* Plan bin and point chunks against the limits of the device that will execute
* them. The largest safe spectrum/bin batch is retained when point chunking can
* satisfy steering memory; this avoids repeating the CPU FFT for every GPU bin
* chunk on wide bands.
*/
export function estimateWebGpuBatchPlan(options) {
const {
workload,
limits,
maximumGpuWorkingBytes = DEFAULT_MAXIMUM_GPU_WORKING_BYTES,
maximumHostWorkingBytes = DEFAULT_MAXIMUM_HOST_WORKING_BYTES,
maximumSteeringBufferBytes = DEFAULT_MAXIMUM_STEERING_BUFFER_BYTES,
maximumSpectrumBatchBins = Number.MAX_SAFE_INTEGER,
maximumMacsPerInvocation = DEFAULT_MAXIMUM_MACS_PER_INVOCATION,
} = options;
if (!workload || !Array.isArray(workload.selectedBins) || workload.selectedBins.length === 0) {
throw new WebGpuLimitError("WebGPU DAS workload에 FFT bin이 없습니다.");
}
requirePositiveInteger(workload.microphoneCount, "microphoneCount");
requirePositiveInteger(workload.pointCount, "pointCount");
requirePositiveInteger(workload.frameCount, "frameCount");
requirePositiveInteger(workload.spectrumBytesPerBin, "spectrumBytesPerBin");
requirePositiveInteger(maximumGpuWorkingBytes, "maximumGpuWorkingBytes");
requirePositiveInteger(maximumHostWorkingBytes, "maximumHostWorkingBytes");
requirePositiveInteger(maximumSteeringBufferBytes, "maximumSteeringBufferBytes");
requirePositiveInteger(maximumMacsPerInvocation, "maximumMacsPerInvocation");
const macsPerInvocation = workload.frameCount * workload.microphoneCount;
if (!Number.isSafeInteger(macsPerInvocation) || macsPerInvocation > maximumMacsPerInvocation) {
throw new WebGpuLimitError(
`DAS invocation당 직렬 누산 ${macsPerInvocation.toLocaleString()}회가 안전 한도 ` +
`${maximumMacsPerInvocation.toLocaleString()}회를 넘습니다. duration을 줄이세요.`,
);
}
const storageBindingLimit = limitValue(limits, "maxStorageBufferBindingSize", 128 * MEBIBYTE);
const bufferLimit = limitValue(limits, "maxBufferSize", 256 * MEBIBYTE);
const maximumDispatch = limitValue(limits, "maxComputeWorkgroupsPerDimension", 65_535);
const maximumInvocations = limitValue(limits, "maxComputeInvocationsPerWorkgroup", 256);
const maximumWorkgroupX = limitValue(limits, "maxComputeWorkgroupSizeX", 256);
const maximumStorageBuffers = limitValue(limits, "maxStorageBuffersPerShaderStage", 8);
const maximumUniformBytes = limitValue(limits, "maxUniformBufferBindingSize", 65_536);
const storageLimit = Math.min(storageBindingLimit, bufferLimit);
if (maximumInvocations < WORKGROUP_SIZE || maximumWorkgroupX < WORKGROUP_SIZE) {
throw new WebGpuLimitError(
`DAS workgroup ${WORKGROUP_SIZE}개 invocation을 지원하지 않는 WebGPU 장치입니다.`,
);
}
if (maximumStorageBuffers < 6) {
throw new WebGpuLimitError(`DAS에 storage buffer 6개가 필요하지만 장치 한도는 ${maximumStorageBuffers}개입니다.`);
}
if (maximumUniformBytes < DAS_PARAMS_BYTE_LENGTH) {
throw new WebGpuLimitError(`DAS uniform ${DAS_PARAMS_BYTE_LENGTH} bytes가 장치 한도를 넘습니다.`);
}
const calibrationBytes = workload.microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2;
if (calibrationBytes > storageLimit) {
throw new WebGpuLimitError(
`마이크 calibration buffer ${calibrationBytes.toLocaleString()} bytes가 storage binding 한도를 넘습니다.`,
);
}
const distanceBytesPerPoint = workload.microphoneCount * Float32Array.BYTES_PER_ELEMENT;
if (distanceBytesPerPoint > storageLimit) {
throw new WebGpuLimitError("scan point 한 개의 distance data도 storage binding에 넣을 수 없습니다.");
}
if (workload.spectrumBytesPerBin > storageLimit) {
throw new WebGpuLimitError(
`FFT bin 한 개의 spectrum ${workload.spectrumBytesPerBin.toLocaleString()} bytes가 storage binding 한도를 넘습니다.`,
);
}
const maximumBinsBySpectrum = Math.floor(storageLimit / workload.spectrumBytesPerBin);
const maximumBinsByDispatch = maximumDispatch;
const requestedBinLimit = Math.max(1, Math.floor(maximumSpectrumBatchBins));
const largestCandidate = Math.min(
workload.selectedBins.length,
maximumBinsBySpectrum,
maximumBinsByDispatch,
requestedBinLimit,
);
if (largestCandidate < 1) throw new WebGpuLimitError("FFT bin 한 개도 WebGPU batch에 넣을 수 없습니다.");
let binBatchSize = largestCandidate;
let pointBatchSize = 0;
for (; binBatchSize >= 1; binBatchSize -= 1) {
pointBatchSize = pointCapacityForBinBatch({
workload,
binBatchSize,
storageBindingLimit,
bufferLimit,
maximumSteeringBufferBytes,
maximumGpuWorkingBytes,
maximumHostWorkingBytes,
maximumPointDispatch: maximumDispatch,
});
if (pointBatchSize >= 1) break;
}
if (pointBatchSize < 1) {
throw new WebGpuLimitError(
"현재 장치 및 애플리케이션 메모리 한도에서는 scan point 한 개도 처리할 수 없습니다.",
);
}
const spectrumBytes = binBatchSize * workload.spectrumBytesPerBin;
const distanceBytes = pointBatchSize * distanceBytesPerPoint;
const steeringBytes =
binBatchSize * pointBatchSize * workload.microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2;
const outputBytes = binBatchSize * pointBatchSize * Float32Array.BYTES_PER_ELEMENT;
const calibrationAndParamsBytes = calibrationBytes + DAS_PARAMS_BYTE_LENGTH;
const estimatedGpuWorkingBytes =
calibrationAndParamsBytes + spectrumBytes + binBatchSize * 4 + distanceBytes + steeringBytes + outputBytes * 2;
return {
binBatchSize,
pointBatchSize,
binBatchCount: Math.ceil(workload.selectedBins.length / binBatchSize),
pointBatchCount: Math.ceil(workload.pointCount / pointBatchSize),
macsPerInvocation,
steeringBytes,
estimatedGpuWorkingBytes,
maximumGpuWorkingBytes,
maximumHostWorkingBytes,
};
}
export function estimateWebGpuBatchSize(options) {
return estimateWebGpuBatchPlan(options).binBatchSize;
}
function gpuGlobals() {
const usage = globalThis.GPUBufferUsage;
const mapMode = globalThis.GPUMapMode;
if (!usage || !mapMode) {
throw new WebGpuUnavailableError("WebGPU buffer 상수를 사용할 수 없습니다.");
}
return { usage, mapMode };
}
function createBuffer(device, { label, byteLength, usage, data }) {
const buffer = device.createBuffer({
label,
size: minimumBufferSize(byteLength),
usage,
});
if (data && data.byteLength > 0) {
device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, data.byteLength);
}
return buffer;
}
function destroyBuffers(buffers) {
for (const buffer of buffers) {
try {
buffer?.destroy();
} catch {
// A lost device can invalidate resources before deterministic cleanup.
}
}
}
async function readOutputBuffer(context, readbackBuffer, byteLength, mapMode) {
const mappedOutcome = readbackBuffer.mapAsync(mapMode.READ).then(
() => ({ kind: "mapped" }),
(error) => ({ kind: "error", error }),
);
const lostOutcome = context.lostPromise.then((info) => ({ kind: "lost", info }));
const outcome = await Promise.race([mappedOutcome, lostOutcome]);
if (outcome.kind === "lost") {
throw new WebGpuExecutionError(`WebGPU 장치가 계산 중 손실되었습니다: ${outcome.info.message || "원인 미상"}`);
}
if (outcome.kind === "error") {
throw new WebGpuExecutionError("WebGPU 결과 buffer를 읽지 못했습니다.", { cause: outcome.error });
}
const copy = readbackBuffer.getMappedRange(0, byteLength).slice(0);
readbackBuffer.unmap();
return new Float32Array(copy);
}
async function executePointBatch(options) {
const {
context,
pipelines,
buffers,
pointCount,
binCount,
microphoneCount,
outputByteLength,
signal,
usage,
mapMode,
} = options;
throwIfAborted(signal);
assertWebGpuContextHealthy(context);
return withWebGpuErrorScopes(context.device, async () => {
const { device } = context;
const steeringBindGroup = device.createBindGroup({
label: "DAS steering bind group",
layout: pipelines.steering.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: buffers.params } },
{ binding: 1, resource: { buffer: buffers.bins } },
{ binding: 3, resource: { buffer: buffers.distances } },
{ binding: 4, resource: { buffer: buffers.calibration } },
{ binding: 5, resource: { buffer: buffers.steering } },
],
});
const dasBindGroup = device.createBindGroup({
label: "DAS beamforming bind group",
layout: pipelines.das.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: buffers.params } },
{ binding: 2, resource: { buffer: buffers.spectrum } },
{ binding: 5, resource: { buffer: buffers.steering } },
{ binding: 6, resource: { buffer: buffers.output } },
],
});
const encoder = device.createCommandEncoder({ label: "DAS command encoder" });
const pass = encoder.beginComputePass({ label: "DAS steering and beamforming pass" });
pass.setPipeline(pipelines.steering);
pass.setBindGroup(0, steeringBindGroup);
pass.dispatchWorkgroups(Math.ceil(microphoneCount / WORKGROUP_SIZE), pointCount, binCount);
pass.setPipeline(pipelines.das);
pass.setBindGroup(0, dasBindGroup);
pass.dispatchWorkgroups(Math.ceil(pointCount / WORKGROUP_SIZE), binCount, 1);
pass.end();
encoder.copyBufferToBuffer(buffers.output, 0, buffers.readback, 0, outputByteLength);
device.queue.submit([encoder.finish()]);
const output = await readOutputBuffer(context, buffers.readback, outputByteLength, mapMode);
throwIfAborted(signal);
assertWebGpuContextHealthy(context);
return output;
});
}
function createBandIndexLookup(workload) {
const lookup = new Map();
workload.bandBins.forEach((bins, bandIndex) => {
bins.forEach((bin) => {
if (!lookup.has(bin)) lookup.set(bin, []);
lookup.get(bin).push(bandIndex);
});
});
return lookup;
}
export async function computeDasWebGpu(options) {
const {
channels,
sampleRate,
arrayDefinition,
scene,
experiment,
signal,
onProgress,
maximumGpuWorkingBytes = DEFAULT_MAXIMUM_GPU_WORKING_BYTES,
maximumHostWorkingBytes = DEFAULT_MAXIMUM_HOST_WORKING_BYTES,
maximumSteeringBufferBytes = DEFAULT_MAXIMUM_STEERING_BUFFER_BYTES,
maximumSpectrumBatchBins = Number.MAX_SAFE_INTEGER,
maximumMacsPerInvocation = DEFAULT_MAXIMUM_MACS_PER_INVOCATION,
maximumGpuComplexMacs = 20_000_000_000,
maximumFftButterflies = 1_000_000_000,
stftTransformBatchSize = 32,
gpu,
powerPreference,
} = options;
if (!Array.isArray(channels) || channels.length !== arrayDefinition?.microphones?.length) {
throw new Error(`채널 수 ${channels?.length ?? 0}와 마이크 수 ${arrayDefinition?.microphones?.length ?? 0}가 다릅니다.`);
}
const totalStartedAt = now();
const workload = estimateDasWorkload({ channels, sampleRate, arrayDefinition, experiment });
if (workload.complexMacCount > maximumGpuComplexMacs) {
throw new WebGpuLimitError(
`WebGPU DAS 예상 복소 누산 ${workload.complexMacCount.toLocaleString()}회가 안전 한도 ` +
`${maximumGpuComplexMacs.toLocaleString()}회를 넘습니다.`,
);
}
const context = await createWebGpuContext({ gpu, powerPreference });
const resources = [];
let stftMs = 0;
let gpuWallMs = 0;
try {
const { usage, mapMode } = gpuGlobals();
assertWebGpuContextHealthy(context);
const plan = estimateWebGpuBatchPlan({
workload,
limits: context.device.limits,
maximumGpuWorkingBytes,
maximumHostWorkingBytes,
maximumSteeringBufferBytes,
maximumSpectrumBatchBins,
maximumMacsPerInvocation,
});
const fftButterflyCount =
plan.binBatchCount *
workload.frameCount *
workload.microphoneCount *
(experiment.stft.fftSize / 2) *
Math.log2(experiment.stft.fftSize);
if (fftButterflyCount > maximumFftButterflies) {
throw new WebGpuLimitError(
`CPU STFT 예상 butterfly ${Math.ceil(fftButterflyCount).toLocaleString()}회가 안전 한도 ` +
`${maximumFftButterflies.toLocaleString()}회를 넘습니다.`,
);
}
const pipelines = await withWebGpuErrorScopes(context.device, async () => ({
steering: await createCheckedComputePipeline(context.device, {
code: DAS_WGSL,
entryPoint: "compute_steering",
label: "DAS steering pipeline",
}),
das: await createCheckedComputePipeline(context.device, {
code: DAS_WGSL,
entryPoint: "compute_das",
label: "DAS beamforming pipeline",
}),
}));
const calibration = packMicrophoneCalibration(arrayDefinition.microphones);
const calibrationBuffer = await withWebGpuErrorScopes(context.device, async () =>
createBuffer(context.device, {
label: "DAS microphone calibration",
byteLength: calibration.byteLength,
usage: usage.STORAGE | usage.COPY_DST,
data: calibration,
}),
);
resources.push(calibrationBuffer);
const distances = createDistanceMatrix(arrayDefinition, experiment.scan);
const rawMaps = experiment.frequencyBands.map(() => new Float32Array(workload.pointCount));
const bandIndexesByBin = createBandIndexLookup(workload);
const totalDispatchUnits = plan.binBatchCount * plan.pointBatchCount;
let completedDispatchUnits = 0;
let completedBins = 0;
for (let binStart = 0; binStart < workload.selectedBins.length; binStart += plan.binBatchSize) {
throwIfAborted(signal);
await yieldToEventLoop();
const batchBins = workload.selectedBins.slice(binStart, binStart + plan.binBatchSize);
const stftStartedAt = now();
const stft = await extractStftBins(channels, {
fftSize: experiment.stft.fftSize,
hopSize: experiment.stft.hopSize,
bins: batchBins,
signal,
transformBatchSize: stftTransformBatchSize,
onProgress: ({ completedTransforms, totalTransforms }) =>
onProgress?.({
stage: "stft",
backend: "webgpu",
completedBins,
totalBins: workload.selectedBins.length,
completedTransforms,
totalTransforms,
spectrumBatch: Math.floor(binStart / plan.binBatchSize) + 1,
spectrumBatchCount: plan.binBatchCount,
}),
});
const packedSpectrum = packStftBatch(stft, batchBins);
stftMs += now() - stftStartedAt;
const binIndices = Uint32Array.from(batchBins);
const binResources = [];
try {
const { binsBuffer, spectrumBuffer } = await withWebGpuErrorScopes(context.device, async () => ({
binsBuffer: createBuffer(context.device, {
label: "DAS FFT bin indices",
byteLength: binIndices.byteLength,
usage: usage.STORAGE | usage.COPY_DST,
data: binIndices,
}),
spectrumBuffer: createBuffer(context.device, {
label: "DAS packed STFT spectrum",
byteLength: packedSpectrum.byteLength,
usage: usage.STORAGE | usage.COPY_DST,
data: packedSpectrum,
}),
}));
binResources.push(binsBuffer, spectrumBuffer);
for (let pointStart = 0; pointStart < workload.pointCount; pointStart += plan.pointBatchSize) {
throwIfAborted(signal);
const pointCount = Math.min(plan.pointBatchSize, workload.pointCount - pointStart);
const distanceStart = pointStart * workload.microphoneCount;
const distanceEnd = distanceStart + pointCount * workload.microphoneCount;
const pointDistances = distances.subarray(distanceStart, distanceEnd);
const params = packDasParams({
pointCount,
microphoneCount: workload.microphoneCount,
frameCount: workload.frameCount,
binCount: batchBins.length,
sampleRate,
fftSize: experiment.stft.fftSize,
speedOfSound: scene.speedOfSound,
});
const steeringByteLength =
batchBins.length * pointCount * workload.microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2;
const outputByteLength = batchBins.length * pointCount * Float32Array.BYTES_PER_ELEMENT;
const pointResources = [];
try {
const {
paramsBuffer,
distancesBuffer,
steeringBuffer,
outputBuffer,
readbackBuffer,
} = await withWebGpuErrorScopes(context.device, async () => ({
paramsBuffer: createBuffer(context.device, {
label: "DAS parameters",
byteLength: params.byteLength,
usage: usage.UNIFORM | usage.COPY_DST,
data: new Uint8Array(params),
}),
distancesBuffer: createBuffer(context.device, {
label: "DAS point-to-microphone distances",
byteLength: pointDistances.byteLength,
usage: usage.STORAGE | usage.COPY_DST,
data: pointDistances,
}),
steeringBuffer: createBuffer(context.device, {
label: "DAS steering vectors",
byteLength: steeringByteLength,
usage: usage.STORAGE,
}),
outputBuffer: createBuffer(context.device, {
label: "DAS output maps",
byteLength: outputByteLength,
usage: usage.STORAGE | usage.COPY_SRC,
}),
readbackBuffer: createBuffer(context.device, {
label: "DAS readback staging",
byteLength: outputByteLength,
usage: usage.MAP_READ | usage.COPY_DST,
}),
}));
pointResources.push(paramsBuffer, distancesBuffer, steeringBuffer, outputBuffer, readbackBuffer);
const gpuStartedAt = now();
const output = await executePointBatch({
context,
pipelines,
buffers: {
params: paramsBuffer,
bins: binsBuffer,
spectrum: spectrumBuffer,
distances: distancesBuffer,
calibration: calibrationBuffer,
steering: steeringBuffer,
output: outputBuffer,
readback: readbackBuffer,
},
pointCount,
binCount: batchBins.length,
microphoneCount: workload.microphoneCount,
outputByteLength,
signal,
usage,
mapMode,
});
gpuWallMs += now() - gpuStartedAt;
for (let outputIndex = 0; outputIndex < output.length; outputIndex += 1) {
if (!Number.isFinite(output[outputIndex]) || output[outputIndex] < 0) {
throw new WebGpuExecutionError(
`WebGPU DAS output[${outputIndex}]이 유효한 power 값이 아닙니다: ${output[outputIndex]}`,
);
}
}
batchBins.forEach((bin, localBinIndex) => {
const localMapOffset = localBinIndex * pointCount;
for (const bandIndex of bandIndexesByBin.get(bin)) {
const rawMap = rawMaps[bandIndex];
for (let localPointIndex = 0; localPointIndex < pointCount; localPointIndex += 1) {
rawMap[pointStart + localPointIndex] += output[localMapOffset + localPointIndex];
}
}
});
} finally {
destroyBuffers(pointResources);
}
completedDispatchUnits += 1;
onProgress?.({
stage: "beamforming",
backend: "webgpu",
completedBins:
pointStart + pointCount === workload.pointCount
? completedBins + batchBins.length
: completedBins,
totalBins: workload.selectedBins.length,
completedUnits: completedDispatchUnits,
totalUnits: totalDispatchUnits,
});
await yieldToEventLoop();
}
} finally {
destroyBuffers(binResources);
}
completedBins += batchBins.length;
}
const bands = finalizeDasBands(rawMaps, workload, experiment);
const totalMs = now() - totalStartedAt;
return {
backend: "webgpu",
frameCount: workload.frameCount,
microphoneCount: workload.microphoneCount,
pointCount: workload.pointCount,
workload,
fftButterflyCount,
bands,
timingMs: {
total: totalMs,
stft: stftMs,
gpuWall: gpuWallMs,
beamforming: totalMs - stftMs,
},
gpu: {
adapterInfo: context.adapterInfo,
timingKind: "wall-clock",
batchPlan: plan,
},
};
} finally {
destroyBuffers(resources);
closeWebGpuContext(context);
}
}
+414
View File
@@ -0,0 +1,414 @@
/**
* Two-pass frequency-domain delay-and-sum beamforming kernel.
*
* Dispatch shapes:
* compute_steering = (ceil(microphoneCount / WORKGROUP_SIZE), pointCount, binCount)
* compute_das = (ceil(pointCount / WORKGROUP_SIZE), binCount, 1)
*
* The first pass computes trigonometry once per (bin, point, microphone).
* The second pass reuses those steering vectors for every STFT frame. Callers
* can bound the intermediate buffer by submitting point/bin batches.
*
* Flat buffer layouts:
* spectrum: [bin][frame][microphone][real, imaginary]
* distances: [point][microphone]
* calibration: [microphone][inverse sensitivity gain, phase offset radians]
* steering: [bin][point][microphone][real, imaginary]
* output: [bin][point]
*/
export const WORKGROUP_SIZE = 64;
export const DAS_WORKGROUP_SIZE = WORKGROUP_SIZE;
export const MAX_MICROPHONES = 1024;
// WGSL uniform structs are rounded to a 16-byte boundary. The reserved word
// keeps the JavaScript and shader representations explicitly 32 bytes.
export const DAS_PARAMS_BYTE_LENGTH = 32;
export const DAS_PARAMS_LAYOUT = Object.freeze({
byteLength: DAS_PARAMS_BYTE_LENGTH,
pointCount: Object.freeze({ offset: 0, type: "u32" }),
microphoneCount: Object.freeze({ offset: 4, type: "u32" }),
frameCount: Object.freeze({ offset: 8, type: "u32" }),
binCount: Object.freeze({ offset: 12, type: "u32" }),
sampleRate: Object.freeze({ offset: 16, type: "f32" }),
fftSize: Object.freeze({ offset: 20, type: "u32" }),
speedOfSound: Object.freeze({ offset: 24, type: "f32" }),
reserved: Object.freeze({ offset: 28, type: "u32" }),
});
export const DAS_BUFFER_LAYOUT = Object.freeze({
params: Object.freeze({ group: 0, binding: 0, access: "uniform", layout: DAS_PARAMS_LAYOUT }),
binIndices: Object.freeze({ group: 0, binding: 1, access: "read", element: "u32" }),
spectrum: Object.freeze({
group: 0,
binding: 2,
access: "read",
element: "f32",
order: "bin-frame-microphone-complex",
}),
distances: Object.freeze({
group: 0,
binding: 3,
access: "read",
element: "f32",
order: "point-microphone",
}),
calibration: Object.freeze({
group: 0,
binding: 4,
access: "read",
element: "vec2<f32>",
order: "microphone-(inverseGain-phaseRadians)",
}),
steering: Object.freeze({
group: 0,
binding: 5,
access: "read_write",
element: "vec2<f32>",
order: "bin-point-microphone-complex",
}),
output: Object.freeze({
group: 0,
binding: 6,
access: "read_write",
element: "f32",
order: "bin-point",
}),
});
export const DAS_ENTRY_POINTS = Object.freeze({
steering: "compute_steering",
beamforming: "compute_das",
});
function requireUint32(name, value) {
if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) {
throw new RangeError(`${name} must be an unsigned 32-bit integer.`);
}
return value;
}
function requireUint32Product(name, values) {
let product = 1;
for (const value of values) {
if (value !== 0 && product > 0xffff_ffff / value) {
throw new RangeError(`${name} exceeds the shader's u32 address space.`);
}
product *= value;
}
return product;
}
function requirePositiveF32(name, value) {
const rounded = Math.fround(value);
if (!(rounded > 0) || !Number.isFinite(rounded)) {
throw new RangeError(`${name} must be a finite positive f32 value.`);
}
return rounded;
}
function requireFiniteF32(name, value) {
const rounded = Math.fround(value);
if (!Number.isFinite(rounded)) {
throw new RangeError(`${name} must be a finite f32 value.`);
}
return rounded;
}
function requireFiniteNumber(name, value) {
if (!Number.isFinite(value)) throw new RangeError(`${name} must be finite.`);
return value;
}
/** Pack the binding-0 uniform using the offsets in DAS_PARAMS_LAYOUT. */
export function packDasParams(options) {
const {
pointCount,
microphoneCount,
frameCount,
binCount,
sampleRate,
fftSize,
speedOfSound,
} = options;
const counts = {
pointCount: requireUint32("pointCount", pointCount),
microphoneCount: requireUint32("microphoneCount", microphoneCount),
frameCount: requireUint32("frameCount", frameCount),
binCount: requireUint32("binCount", binCount),
fftSize: requireUint32("fftSize", fftSize),
};
if (counts.microphoneCount > MAX_MICROPHONES) {
throw new RangeError(`microphoneCount must not exceed ${MAX_MICROPHONES}.`);
}
if (counts.fftSize === 0) throw new RangeError("fftSize must be greater than zero.");
requireUint32Product("distance buffer element count", [
counts.pointCount,
counts.microphoneCount,
]);
requireUint32Product("output buffer element count", [counts.binCount, counts.pointCount]);
requireUint32Product("steering buffer element count", [
counts.binCount,
counts.pointCount,
counts.microphoneCount,
]);
requireUint32Product("spectrum buffer element count", [
counts.binCount,
counts.frameCount,
counts.microphoneCount,
2,
]);
const packedSampleRate = requirePositiveF32("sampleRate", sampleRate);
const packedSpeedOfSound = requirePositiveF32("speedOfSound", speedOfSound);
const buffer = new ArrayBuffer(DAS_PARAMS_BYTE_LENGTH);
const view = new DataView(buffer);
view.setUint32(0, counts.pointCount, true);
view.setUint32(4, counts.microphoneCount, true);
view.setUint32(8, counts.frameCount, true);
view.setUint32(12, counts.binCount, true);
view.setFloat32(16, packedSampleRate, true);
view.setUint32(20, counts.fftSize, true);
view.setFloat32(24, packedSpeedOfSound, true);
view.setUint32(28, 0, true);
return buffer;
}
/**
* Pack already-linear calibration as tightly packed vec2<f32> entries.
* phase is the microphone phase offset in radians. The shader subtracts it
* from the propagation phase, exactly as the CPU reference does.
*/
export function packDasCalibration(calibration) {
if (!Array.isArray(calibration)) throw new TypeError("calibration must be an array.");
const packed = new Float32Array(calibration.length * 2);
for (let index = 0; index < calibration.length; index += 1) {
const entry = calibration[index];
const gain = requireFiniteF32(`calibration[${index}].gain`, entry?.gain);
if (gain < 0) throw new RangeError(`calibration[${index}].gain must not be negative.`);
packed[index * 2] = gain;
packed[index * 2 + 1] = requireFiniteF32(
`calibration[${index}].phase`,
entry?.phase,
);
}
return packed;
}
/** Convert array-definition microphone metadata to the shader calibration layout. */
export function packMicrophoneCalibration(microphones) {
if (!Array.isArray(microphones)) throw new TypeError("microphones must be an array.");
const packed = new Float32Array(microphones.length * 2);
for (let index = 0; index < microphones.length; index += 1) {
const microphone = microphones[index];
const sensitivityDb = requireFiniteNumber(
`microphones[${index}].sensitivityDb`,
microphone?.sensitivityDb ?? 0,
);
const phaseOffsetDeg = requireFiniteNumber(
`microphones[${index}].phaseOffsetDeg`,
microphone?.phaseOffsetDeg ?? 0,
);
packed[index * 2] = requireFiniteF32(
`microphones[${index}] inverse sensitivity gain`,
10 ** (-sensitivityDb / 20),
);
packed[index * 2 + 1] = requireFiniteF32(
`microphones[${index}] phase offset`,
(phaseOffsetDeg * Math.PI) / 180,
);
}
return packed;
}
export const DAS_WGSL = /* wgsl */ `
struct DasParams {
point_count: u32,
microphone_count: u32,
frame_count: u32,
bin_count: u32,
sample_rate: f32,
fft_size: u32,
speed_of_sound: f32,
reserved: u32,
}
struct U32Buffer {
values: array<u32>,
}
struct F32Buffer {
values: array<f32>,
}
struct Vec2Buffer {
values: array<vec2<f32>>,
}
@group(0) @binding(0) var<uniform> params: DasParams;
@group(0) @binding(1) var<storage, read> bin_indices: U32Buffer;
@group(0) @binding(2) var<storage, read> spectrum: F32Buffer;
@group(0) @binding(3) var<storage, read> distances: F32Buffer;
@group(0) @binding(4) var<storage, read> calibration: Vec2Buffer;
@group(0) @binding(5) var<storage, read_write> steering_vectors: Vec2Buffer;
@group(0) @binding(6) var<storage, read_write> output_maps: F32Buffer;
const TAU: f32 = 6.283185307179586;
const MAX_MICROPHONES: u32 = ${MAX_MICROPHONES}u;
const MAX_U32: u32 = 0xffffffffu;
fn multiplication_overflows(left: u32, right: u32) -> bool {
return left != 0u && right > MAX_U32 / left;
}
fn wrap_phase(phase: f32) -> f32 {
return phase - round(phase / TAU) * TAU;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, 1, 1)
fn compute_steering(@builtin(global_invocation_id) invocation: vec3<u32>) {
let microphone_index = invocation.x;
let point_index = invocation.y;
let bin_index = invocation.z;
if (
microphone_index >= params.microphone_count ||
point_index >= params.point_count ||
bin_index >= params.bin_count
) {
return;
}
if (
params.microphone_count == 0u ||
params.microphone_count > MAX_MICROPHONES ||
params.fft_size == 0u ||
params.sample_rate <= 0.0 ||
params.speed_of_sound <= 0.0 ||
multiplication_overflows(params.point_count, params.microphone_count) ||
multiplication_overflows(params.bin_count, params.point_count)
) {
return;
}
let bin_point_count = params.bin_count * params.point_count;
if (multiplication_overflows(bin_point_count, params.microphone_count)) {
return;
}
let required_distance_values = params.point_count * params.microphone_count;
let required_steering_values = bin_point_count * params.microphone_count;
if (
arrayLength(&bin_indices.values) < params.bin_count ||
arrayLength(&calibration.values) < params.microphone_count ||
arrayLength(&distances.values) < required_distance_values ||
arrayLength(&steering_vectors.values) < required_steering_values
) {
return;
}
let bin = f32(bin_indices.values[bin_index]);
let angular_wave_number =
(TAU * bin * params.sample_rate) /
(f32(params.fft_size) * params.speed_of_sound);
let distance_index = point_index * params.microphone_count + microphone_index;
let steering_index =
(bin_index * params.point_count + point_index) * params.microphone_count +
microphone_index;
let microphone_calibration = calibration.values[microphone_index];
let phase = wrap_phase(
angular_wave_number * distances.values[distance_index] -
microphone_calibration.y
);
steering_vectors.values[steering_index] =
vec2<f32>(cos(phase), sin(phase)) * microphone_calibration.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, 1, 1)
fn compute_das(@builtin(global_invocation_id) invocation: vec3<u32>) {
let point_index = invocation.x;
let bin_index = invocation.y;
if (point_index >= params.point_count || bin_index >= params.bin_count) {
return;
}
if (
params.microphone_count == 0u ||
params.microphone_count > MAX_MICROPHONES ||
params.frame_count == 0u ||
multiplication_overflows(params.bin_count, params.point_count) ||
multiplication_overflows(params.bin_count, params.frame_count)
) {
return;
}
let bin_point_count = params.bin_count * params.point_count;
let bin_frame_count = params.bin_count * params.frame_count;
if (
multiplication_overflows(bin_point_count, params.microphone_count) ||
multiplication_overflows(bin_frame_count, params.microphone_count)
) {
return;
}
let spectrum_complex_count = bin_frame_count * params.microphone_count;
if (multiplication_overflows(spectrum_complex_count, 2u)) {
return;
}
let required_output_values = bin_point_count;
let required_steering_values = bin_point_count * params.microphone_count;
let required_spectrum_values = spectrum_complex_count * 2u;
if (
arrayLength(&output_maps.values) < required_output_values ||
arrayLength(&steering_vectors.values) < required_steering_values ||
arrayLength(&spectrum.values) < required_spectrum_values
) {
return;
}
let microphone_scale = 1.0 / f32(params.microphone_count);
let steering_base =
(bin_index * params.point_count + point_index) * params.microphone_count;
var accumulated_power = 0.0;
for (var frame_index = 0u; frame_index < params.frame_count; frame_index += 1u) {
var sum = vec2<f32>(0.0, 0.0);
for (
var microphone_index = 0u;
microphone_index < params.microphone_count;
microphone_index += 1u
) {
let complex_index = (
(
(bin_index * params.frame_count + frame_index) * params.microphone_count +
microphone_index
) * 2u
);
let input = vec2<f32>(
spectrum.values[complex_index],
spectrum.values[complex_index + 1u]
);
let steering = steering_vectors.values[steering_base + microphone_index];
// (input.real + i*input.imaginary) *
// (steering.real + i*steering.imaginary)
sum += vec2<f32>(
input.x * steering.x - input.y * steering.y,
input.x * steering.y + input.y * steering.x
);
}
accumulated_power +=
dot(sum, sum) * microphone_scale * microphone_scale;
}
let output_index = bin_index * params.point_count + point_index;
output_maps.values[output_index] = accumulated_power / f32(params.frame_count);
}
`;
export const DAS_SHADER_SOURCE = DAS_WGSL;
+170
View File
@@ -0,0 +1,170 @@
/** Errors in this module are deliberately typed so `auto` can fall back to CPU
* without hiding invalid experiment input or an explicit user cancellation. */
export class WebGpuError extends Error {
constructor(message, options = {}) {
super(message, options.cause ? { cause: options.cause } : undefined);
this.name = new.target.name;
this.code = options.code ?? "WEBGPU_ERROR";
}
}
export class WebGpuUnavailableError extends WebGpuError {
constructor(message = "이 브라우저에서 WebGPU를 사용할 수 없습니다.", options = {}) {
super(message, { ...options, code: "WEBGPU_UNAVAILABLE" });
}
}
export class WebGpuLimitError extends WebGpuError {
constructor(message, options = {}) {
super(message, { ...options, code: "WEBGPU_LIMIT" });
}
}
export class WebGpuExecutionError extends WebGpuError {
constructor(message, options = {}) {
super(message, { ...options, code: "WEBGPU_EXECUTION" });
}
}
export function isRecoverableWebGpuError(error) {
return error instanceof WebGpuError;
}
function describeAdapter(adapter) {
const info = adapter?.info;
if (!info) return null;
return {
vendor: info.vendor ?? "",
architecture: info.architecture ?? "",
device: info.device ?? "",
description: info.description ?? "",
};
}
function normalizeRequestError(error, message) {
if (error instanceof WebGpuError) return error;
return new WebGpuUnavailableError(message, { cause: error });
}
/**
* Request one short-lived WebGPU context. A new call always requests a new
* adapter so a device-loss retry never reuses an invalid adapter/device pair.
*/
export async function createWebGpuContext(options = {}) {
const gpu = options.gpu ?? globalThis.navigator?.gpu;
if (!gpu) throw new WebGpuUnavailableError();
let adapter;
try {
adapter = await gpu.requestAdapter({ powerPreference: options.powerPreference ?? "high-performance" });
} catch (error) {
throw normalizeRequestError(error, "WebGPU 어댑터 요청에 실패했습니다.");
}
if (!adapter) throw new WebGpuUnavailableError("사용 가능한 WebGPU 어댑터가 없습니다.");
let device;
try {
device = await adapter.requestDevice();
} catch (error) {
throw normalizeRequestError(error, "WebGPU 장치 요청에 실패했습니다.");
}
const state = {
lostInfo: null,
uncapturedError: null,
closedByApplication: false,
};
const onUncapturedError = (event) => {
state.uncapturedError = event.error ?? new Error("알 수 없는 WebGPU 오류");
};
device.addEventListener?.("uncapturederror", onUncapturedError);
const lostPromise = device.lost.then((info) => {
state.lostInfo = info;
return info;
});
return {
adapter,
device,
adapterInfo: describeAdapter(adapter),
state,
lostPromise,
onUncapturedError,
};
}
export function assertWebGpuContextHealthy(context) {
const { state } = context;
if (state.uncapturedError) {
throw new WebGpuExecutionError(`처리되지 않은 WebGPU 오류: ${state.uncapturedError.message}`, {
cause: state.uncapturedError,
});
}
if (state.lostInfo && !(state.closedByApplication && state.lostInfo.reason === "destroyed")) {
throw new WebGpuExecutionError(`WebGPU 장치가 손실되었습니다: ${state.lostInfo.message || "원인 미상"}`);
}
}
export function closeWebGpuContext(context) {
if (!context?.device) return;
context.state.closedByApplication = true;
context.device.removeEventListener?.("uncapturederror", context.onUncapturedError);
context.device.destroy();
}
export async function createCheckedComputePipeline(device, { code, entryPoint, label }) {
let shaderModule;
try {
shaderModule = device.createShaderModule({ code, label: `${label} shader` });
const compilationInfo = await shaderModule.getCompilationInfo();
const compilationErrors = compilationInfo.messages.filter((message) => message.type === "error");
if (compilationErrors.length > 0) {
const details = compilationErrors
.slice(0, 4)
.map((message) => `${message.lineNum}:${message.linePos} ${message.message}`)
.join("\n");
throw new WebGpuExecutionError(`WGSL 컴파일에 실패했습니다.\n${details}`);
}
return await device.createComputePipelineAsync({
label,
layout: "auto",
compute: { module: shaderModule, entryPoint },
});
} catch (error) {
if (error instanceof WebGpuError) throw error;
throw new WebGpuExecutionError("WebGPU compute pipeline 생성에 실패했습니다.", { cause: error });
}
}
/** Capture validation/OOM/internal errors around predictable GPU work. */
export async function withWebGpuErrorScopes(device, callback) {
const filters = ["internal", "out-of-memory", "validation"];
for (const filter of filters) device.pushErrorScope(filter);
let result;
let callbackError;
try {
result = await callback();
} catch (error) {
callbackError = error;
}
const captured = [];
for (let index = filters.length - 1; index >= 0; index -= 1) {
try {
const error = await device.popErrorScope();
if (error) captured.push(error);
} catch (error) {
captured.push(error);
}
}
if (callbackError) {
if (callbackError instanceof WebGpuError) throw callbackError;
throw new WebGpuExecutionError("WebGPU 명령 실행에 실패했습니다.", { cause: callbackError });
}
if (captured.length > 0) {
throw new WebGpuExecutionError(`WebGPU 검증 오류: ${captured.map((error) => error.message).join(" / ")}`, {
cause: captured[0],
});
}
return result;
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./compute.js";
export * from "./das.js";
export * from "./das.wgsl.js";
+9
View File
@@ -1,3 +1,12 @@
# Schemas
마이크 배열, scene, experiment, result의 versioned JSON Schema를 둡니다.
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과 평가 지표
중복 id, Nyquist, 시간 구간과 참조 무결성은 `packages/contracts/validation.js`에서 추가로 검증합니다.
+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://micarrsim.local/schemas/array.schema.json",
"title": "micarrsim microphone array",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "kind", "id", "name", "units", "coordinateSystem", "aperture", "microphones"],
"properties": {
"schemaVersion": { "const": "1.0.0" },
"kind": { "const": "micarrsim.array" },
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"name": { "type": "string", "minLength": 1, "maxLength": 120 },
"description": { "type": "string", "maxLength": 1000 },
"units": { "const": "m" },
"coordinateSystem": { "$ref": "#/$defs/coordinateSystem" },
"aperture": {
"type": "object", "additionalProperties": false, "required": ["width", "height"],
"properties": {
"width": { "type": "number", "exclusiveMinimum": 0, "maximum": 20 },
"height": { "type": "number", "exclusiveMinimum": 0, "maximum": 20 }
}
},
"microphones": { "type": "array", "minItems": 1, "maxItems": 1024, "items": { "$ref": "#/$defs/microphone" } }
},
"$defs": {
"coordinateSystem": {
"type": "object", "additionalProperties": false,
"required": ["handedness", "xAxis", "yAxis", "zAxis"],
"properties": {
"handedness": { "const": "right" }, "xAxis": { "const": "right" },
"yAxis": { "const": "up" }, "zAxis": { "const": "toward-scene" }
}
},
"position": {
"type": "array",
"prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
"items": false, "minItems": 3, "maxItems": 3
},
"microphone": {
"type": "object", "additionalProperties": false, "required": ["id", "position"],
"properties": {
"id": { "type": "string", "minLength": 1, "maxLength": 64 },
"position": { "$ref": "#/$defs/position" },
"sensitivityDb": { "type": "number", "minimum": -120, "maximum": 120 },
"phaseOffsetDeg": { "type": "number", "minimum": -180, "maximum": 180 }
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://micarrsim.local/schemas/experiment.schema.json",
"title": "micarrsim experiment",
"type": "object", "additionalProperties": false,
"required": ["schemaVersion", "kind", "id", "name", "arrayId", "sceneId", "scan", "frequencyBands", "stft", "beamforming"],
"properties": {
"schemaVersion": { "const": "1.0.0" }, "kind": { "const": "micarrsim.experiment" },
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"name": { "type": "string", "minLength": 1, "maxLength": 120 },
"arrayId": { "type": "string", "minLength": 1 }, "sceneId": { "type": "string", "minLength": 1 },
"scan": {
"type": "object", "additionalProperties": false,
"required": ["z", "xMin", "xMax", "yMin", "yMax", "columns", "rows"],
"properties": {
"z": { "type": "number", "exclusiveMinimum": 0 },
"xMin": { "type": "number" }, "xMax": { "type": "number" },
"yMin": { "type": "number" }, "yMax": { "type": "number" },
"columns": { "type": "integer", "minimum": 8, "maximum": 512 },
"rows": { "type": "integer", "minimum": 8, "maximum": 512 }
}
},
"frequencyBands": {
"type": "array", "minItems": 1, "maxItems": 8,
"items": {
"type": "object", "additionalProperties": false, "required": ["id", "minHz", "maxHz", "color"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"minHz": { "type": "number", "minimum": 0 }, "maxHz": { "type": "number", "exclusiveMinimum": 0 },
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }
}
}
},
"stft": {
"type": "object", "additionalProperties": false, "required": ["fftSize", "hopSize", "window"],
"properties": {
"fftSize": { "enum": [128, 256, 512, 1024, 2048, 4096] },
"hopSize": { "type": "integer", "minimum": 1, "maximum": 4096 }, "window": { "const": "hann" }
}
},
"beamforming": {
"type": "object", "additionalProperties": false,
"required": ["algorithm", "backend", "minimumDistance", "normalization", "dynamicRangeDb"],
"properties": {
"algorithm": { "const": "das" }, "backend": { "enum": ["auto", "cpu", "webgpu"] },
"minimumDistance": { "type": "number", "exclusiveMinimum": 0, "maximum": 10 },
"normalization": { "enum": ["peak", "none"] },
"dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 }
}
}
}
}
+124
View File
@@ -0,0 +1,124 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://micarrsim.local/schemas/result.schema.json",
"title": "micarrsim simulation result",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion", "kind", "experimentId", "arrayId", "sceneId", "createdAt", "backend",
"inputHash", "signalStats", "timingMs", "bands"
],
"properties": {
"schemaVersion": { "const": "1.0.0" },
"kind": { "const": "micarrsim.result" },
"experimentId": { "type": "string", "minLength": 1 },
"arrayId": { "type": "string", "minLength": 1 },
"sceneId": { "type": "string", "minLength": 1 },
"createdAt": { "type": "string", "format": "date-time" },
"backend": { "enum": ["cpu", "webgpu"] },
"inputHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"signalStats": {
"type": "object", "additionalProperties": false,
"required": ["preNormalizationPeak", "appliedScale", "clippedSampleCount"],
"properties": {
"preNormalizationPeak": { "type": "number", "minimum": 0 },
"appliedScale": { "type": "number", "minimum": 0 },
"clippedSampleCount": { "type": "integer", "minimum": 0 }
}
},
"timingMs": {
"type": "object", "additionalProperties": false, "required": ["total", "beamforming"],
"properties": {
"total": { "type": "number", "minimum": 0 },
"signalGeneration": { "type": "number", "minimum": 0 },
"beamforming": { "type": "number", "minimum": 0 }
}
},
"environment": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
"bands": {
"type": "array", "minItems": 1, "maxItems": 8,
"items": { "$ref": "#/$defs/bandResult" }
}
},
"$defs": {
"position": {
"type": "array",
"prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
"items": false, "minItems": 3, "maxItems": 3
},
"map": {
"type": "object", "additionalProperties": false,
"required": ["rows", "columns", "scale", "values"],
"properties": {
"rows": { "type": "integer", "minimum": 1, "maximum": 512 },
"columns": { "type": "integer", "minimum": 1, "maximum": 512 },
"scale": { "enum": ["power", "normalized", "db"] },
"values": {
"type": "array", "minItems": 1, "maxItems": 262144,
"items": { "type": "number" }
}
}
},
"peak": {
"type": "object", "additionalProperties": false,
"required": ["position", "value"],
"properties": {
"sourceId": { "type": "string", "minLength": 1 },
"position": { "$ref": "#/$defs/position" },
"value": { "type": "number", "minimum": 0 },
"localizationErrorM": { "type": "number", "minimum": 0 },
"localizationErrorPixels": { "type": "number", "minimum": 0 }
}
},
"evaluation": {
"type": "object", "additionalProperties": false,
"required": [
"version", "truthRadiusM", "matchToleranceM", "detectionThresholdRatio",
"detectionThresholdValue", "minimumPeakDistanceM", "sidelobeExclusionRadiusM",
"halfPowerRatio", "dynamicRangeDb", "maximumDetections"
],
"properties": {
"version": { "const": "1.0.0" },
"truthRadiusM": { "type": "number", "minimum": 0 },
"matchToleranceM": { "type": "number", "minimum": 0 },
"detectionThresholdRatio": { "type": "number", "minimum": 0, "maximum": 1 },
"detectionThresholdValue": { "type": "number", "minimum": 0 },
"minimumPeakDistanceM": { "type": "number", "minimum": 0 },
"sidelobeExclusionRadiusM": { "type": "number", "minimum": 0 },
"halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
"dynamicRangeDb": { "type": "number", "minimum": 0 },
"maximumDetections": { "type": "integer", "minimum": 1, "maximum": 128 }
}
},
"processedMap": {
"type": "object", "additionalProperties": false, "required": ["algorithm", "map"],
"properties": {
"algorithm": { "enum": ["damas", "clean-sc", "custom"] },
"map": { "$ref": "#/$defs/map" }
}
},
"bandResult": {
"type": "object", "additionalProperties": false,
"required": ["id", "color", "rawMap", "peaks", "metrics"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
"rawMap": { "$ref": "#/$defs/map" },
"displayMap": { "$ref": "#/$defs/map" },
"evaluation": { "$ref": "#/$defs/evaluation" },
"processedMaps": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/processedMap" } },
"peaks": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/peak" } },
"metrics": {
"type": "object", "additionalProperties": false,
"properties": {
"peakToSidelobeDb": { "type": "number" },
"energyInsideTruthRatio": { "type": "number", "minimum": 0, "maximum": 1 },
"precision": { "type": "number", "minimum": 0, "maximum": 1 },
"recall": { "type": "number", "minimum": 0, "maximum": 1 },
"mainLobeWidthM": { "type": "number", "minimum": 0 }
}
}
}
}
}
}
+95
View File
@@ -0,0 +1,95 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://micarrsim.local/schemas/scene.schema.json",
"title": "micarrsim acoustic scene",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "kind", "id", "name", "units", "sampleRate", "duration", "speedOfSound", "seed", "mix", "sources"],
"properties": {
"schemaVersion": { "const": "1.0.0" }, "kind": { "const": "micarrsim.scene" },
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"name": { "type": "string", "minLength": 1, "maxLength": 120 },
"description": { "type": "string", "maxLength": 1000 }, "units": { "const": "m" },
"sampleRate": { "type": "integer", "minimum": 8000, "maximum": 192000 },
"duration": { "type": "number", "exclusiveMinimum": 0, "maximum": 300 },
"speedOfSound": { "type": "number", "minimum": 250, "maximum": 400 },
"seed": { "type": "integer", "minimum": 0, "maximum": 4294967295 },
"mix": {
"type": "object", "additionalProperties": false, "required": ["normalization", "targetPeak"],
"properties": {
"normalization": { "enum": ["none", "peak"] },
"targetPeak": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }
}
},
"image": { "$ref": "#/$defs/image" },
"assets": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/asset" } },
"sources": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/source" } },
"ambient": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/ambient" } }
},
"$defs": {
"position": {
"type": "array", "prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
"items": false, "minItems": 3, "maxItems": 3
},
"image": {
"type": "object", "additionalProperties": false,
"required": ["assetId", "widthPixels", "heightPixels", "plane"],
"properties": {
"assetId": { "type": "string", "minLength": 1 },
"widthPixels": { "type": "integer", "minimum": 1, "maximum": 32768 },
"heightPixels": { "type": "integer", "minimum": 1, "maximum": 32768 },
"plane": {
"type": "object", "additionalProperties": false,
"required": ["z", "xMin", "xMax", "yMin", "yMax"],
"properties": {
"z": { "type": "number", "exclusiveMinimum": 0 },
"xMin": { "type": "number" }, "xMax": { "type": "number" },
"yMin": { "type": "number" }, "yMax": { "type": "number" }
}
}
}
},
"asset": {
"type": "object", "additionalProperties": false, "required": ["id", "kind", "fileName", "mimeType"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"kind": { "enum": ["audio", "image"] }, "fileName": { "type": "string", "minLength": 1, "maxLength": 255 },
"mimeType": { "type": "string", "minLength": 3, "maxLength": 100 },
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
}
},
"signal": {
"oneOf": [
{ "type": "object", "additionalProperties": false, "required": ["type", "frequency"], "properties": {
"type": { "const": "tone" }, "frequency": { "type": "number", "exclusiveMinimum": 0 },
"phaseDeg": { "type": "number", "minimum": -360, "maximum": 360 }
}},
{ "type": "object", "additionalProperties": false, "required": ["type", "color"], "properties": {
"type": { "const": "noise" }, "color": { "enum": ["white", "pink"] },
"seedOffset": { "type": "integer", "minimum": 0, "maximum": 4294967295 }
}},
{ "type": "object", "additionalProperties": false, "required": ["type", "assetId"], "properties": {
"type": { "const": "asset" }, "assetId": { "type": "string", "minLength": 1 },
"channel": { "type": "integer", "minimum": 0, "maximum": 255 }, "loop": { "type": "boolean" }
}}
]
},
"source": {
"type": "object", "additionalProperties": false,
"required": ["id", "name", "position", "gainDb", "startTime", "duration", "signal"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"name": { "type": "string", "minLength": 1, "maxLength": 120 }, "position": { "$ref": "#/$defs/position" },
"gainDb": { "type": "number", "minimum": -120, "maximum": 24 }, "startTime": { "type": "number", "minimum": 0 },
"duration": { "type": "number", "exclusiveMinimum": 0 }, "signal": { "$ref": "#/$defs/signal" }
}
},
"ambient": {
"type": "object", "additionalProperties": false, "required": ["id", "gainDb", "signal"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"gainDb": { "type": "number", "minimum": -120, "maximum": 24 }, "signal": { "$ref": "#/$defs/signal" }
}
}
}
}
+83
View File
@@ -0,0 +1,83 @@
$ErrorActionPreference = 'Stop'
$projectRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$outputPath = Join-Path $projectRoot 'assets/array-geometries/rectangular-16x12.array.json'
$microphones = [System.Collections.Generic.List[object]]::new()
$columns = 16
$rows = 12
$width = 0.45
$height = 0.45
for ($row = 0; $row -lt $rows; $row++) {
for ($column = 0; $column -lt $columns; $column++) {
$index = $row * $columns + $column
$x = -$width / 2 + $column * $width / ($columns - 1)
$y = -$height / 2 + $row * $height / ($rows - 1)
$microphones.Add([ordered]@{
id = 'mic-{0:D3}' -f ($index + 1)
position = @([Math]::Round($x, 6), [Math]::Round($y, 6), 0)
})
}
}
$arrayDefinition = [ordered]@{
schemaVersion = '1.0.0'
kind = 'micarrsim.array'
id = 'rectangular-16x12'
name = '16x12 rectangular baseline'
description = '192-channel uniform rectangular layout on a 0.45 m square aperture'
units = 'm'
coordinateSystem = [ordered]@{
handedness = 'right'
xAxis = 'right'
yAxis = 'up'
zAxis = 'toward-scene'
}
aperture = [ordered]@{ width = $width; height = $height }
microphones = $microphones
}
$json = $arrayDefinition | ConvertTo-Json -Depth 8
[System.IO.File]::WriteAllText($outputPath, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false))
Write-Host "Generated $($microphones.Count) microphones: $outputPath"
$spiralOutputPath = Join-Path $projectRoot 'assets/array-geometries/fermat-spiral-192.array.json'
$spiralMicrophones = [System.Collections.Generic.List[object]]::new()
$microphoneCount = 192
$radius = 0.225
$goldenAngle = [Math]::PI * (3 - [Math]::Sqrt(5))
for ($index = 0; $index -lt $microphoneCount; $index++) {
$microphoneRadius = $radius * [Math]::Sqrt(($index + 0.5) / $microphoneCount)
$angle = $index * $goldenAngle
$x = $microphoneRadius * [Math]::Cos($angle)
$y = $microphoneRadius * [Math]::Sin($angle)
$spiralMicrophones.Add([ordered]@{
id = 'mic-{0:D3}' -f ($index + 1)
position = @([Math]::Round($x, 6), [Math]::Round($y, 6), 0)
})
}
$spiralDefinition = [ordered]@{
schemaVersion = '1.0.0'
kind = 'micarrsim.array'
id = 'fermat-spiral-192'
name = '192-channel Fermat spiral'
description = 'Equal-area Fermat spiral on a 0.45 m circular aperture for comparison with the rectangular baseline'
units = 'm'
coordinateSystem = [ordered]@{
handedness = 'right'
xAxis = 'right'
yAxis = 'up'
zAxis = 'toward-scene'
}
aperture = [ordered]@{ width = 0.45; height = 0.45 }
microphones = $spiralMicrophones
}
$spiralJson = $spiralDefinition | ConvertTo-Json -Depth 8
[System.IO.File]::WriteAllText(
$spiralOutputPath,
$spiralJson + [Environment]::NewLine,
[System.Text.UTF8Encoding]::new($false)
)
Write-Host "Generated $($spiralMicrophones.Count) microphones: $spiralOutputPath"
+24 -1
View File
@@ -42,7 +42,15 @@ function Send-TextResponse {
}
try {
$listener.Start()
try {
$listener.Start()
}
catch [System.Net.HttpListenerException] {
throw [System.InvalidOperationException]::new(
"Port $Port is unavailable. Close the running micarrsim window or choose another port, for example: start.cmd -Port 4174",
$_.Exception
)
}
Write-Host "micarrsim is running at $serverUrl" -ForegroundColor Green
Write-Host 'Press Ctrl+C to stop.' -ForegroundColor DarkGray
@@ -54,11 +62,26 @@ try {
$context = $listener.GetContext()
try {
$context.Response.Headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self' blob:; worker-src 'self'; connect-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'none'"
$context.Response.Headers['Referrer-Policy'] = 'no-referrer'
$context.Response.Headers['X-Content-Type-Options'] = 'nosniff'
if ($context.Request.HttpMethod -notin @('GET', 'HEAD')) {
$context.Response.Headers['Allow'] = 'GET, HEAD'
Send-TextResponse -Response $context.Response -StatusCode 405 -Message 'Method not allowed'
continue
}
$relativePath = [System.Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/'))
$relativePath = $relativePath.Replace('/', [System.IO.Path]::DirectorySeparatorChar)
if ([string]::IsNullOrWhiteSpace($relativePath)) {
$relativePath = 'index.html'
}
$pathSegments = $relativePath -split '[\\/]'
if ($pathSegments | Where-Object { $_.StartsWith('.') }) {
Send-TextResponse -Response $context.Response -StatusCode 403 -Message 'Forbidden'
continue
}
$candidatePath = [System.IO.Path]::GetFullPath((Join-Path $projectRoot $relativePath))
$isWithinRoot = $candidatePath.Equals($projectRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
+1 -1
View File
@@ -1,5 +1,5 @@
@echo off
setlocal
cd /d "%~dp0"
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\serve.ps1"
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\serve.ps1" %*
if errorlevel 1 pause
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>micarrsim tests</title>
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
<style>
.test-shell { width: min(900px, calc(100% - 32px)); margin: 40px auto; }
.test-summary { color: var(--muted); }
.test-list { display: grid; gap: 8px; padding: 0; list-style: none; }
.test-result { padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
.test-result--pass { border-left: 4px solid var(--mint); }
.test-result--fail { border-left: 4px solid var(--danger); }
pre { overflow: auto; color: var(--danger); white-space: pre-wrap; }
</style>
</head>
<body>
<main class="test-shell">
<p class="eyebrow">Browser test runner</p>
<h1>micarrsim tests</h1>
<p class="test-summary" id="test-summary">테스트 실행 중…</p>
<ol class="test-list" id="test-results"></ol>
</main>
<script type="module" src="/tests/test-runner.js"></script>
</body>
</html>
+28 -1
View File
@@ -1,3 +1,30 @@
# Integration Tests
두 앱 사이 manifest 계약과 end-to-end golden scenario를 검증합니다.
실제 브라우저 API, Worker, 다운로드와 앱 간 데이터 계약을 함께 검증합니다.
## 실행
1. 저장소의 `start.cmd`를 실행합니다.
2. 최신 Edge 또는 Chrome에서 <http://127.0.0.1:4173/tests/>을 엽니다.
3. 모든 항목이 끝날 때까지 페이지를 열어 둡니다.
Unit과 integration test가 같은 페이지에서 실행됩니다. WebGPU 장치 초기화와 192채널 baseline은
수 초 이상 걸릴 수 있습니다. 페이지 제목과 요약이 `PASS`인지 확인하고 실패 항목의 stack을
확인하세요. `index.html`을 직접 열면 HTTP, module과 Worker 테스트가 올바르게 동작하지 않습니다.
## 범위
- `cpu-baseline.test.js`: 192채널·50×50 synthetic fixture의 CPU DAS와 위치 기준
- `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 비교
- `workflow-ui.test.js`: Dataset Builder의 Scene JSON과 원본 assets를 Acoustic Camera에 넘겨
Result JSON을 저장하는 end-to-end 흐름
WebGPU adapter가 없는 환경에서는 장치가 필요한 비교 항목을 건너뛰거나 Auto fallback을 검증하므로
CPU-only PASS도 가능합니다. 실제 장치의 scaling과 benchmark JSON 저장은
<http://127.0.0.1:4173/benchmarks/webgpu-scaling.html>에서 별도로 실행하세요. 기존 실측 조건과
해석은 [`../../docs/benchmarks/README.md`](../../docs/benchmarks/README.md)에 있습니다.
+194
View File
@@ -0,0 +1,194 @@
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
function assertNear(actual, expected, tolerance, label) {
assert(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected}, received ${actual}`);
}
async function waitFor(predicate, label, timeoutMs = 15000) {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
const value = predicate();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(`${label} 대기 시간이 초과됐습니다.`);
}
function dispatchValue(windowObject, element, value, eventName = "input") {
element.value = String(value);
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
}
function dispatchChecked(windowObject, element, checked) {
element.checked = checked;
element.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
}
function dispatchFiles(windowObject, input, files) {
const transfer = new windowObject.DataTransfer();
files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
input.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
}
async function digestHex(blob) {
const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
}
test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 preview WAV를 재현한다", async () => {
const frame = document.createElement("iframe");
frame.src = "/apps/dataset-builder/?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("Builder iframe load failed")), { once: true });
});
const windowObject = frame.contentWindow;
const documentObject = frame.contentDocument;
const objectUrls = [];
const downloads = [];
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url);
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
downloads.push({ fileName: this.download, url: this.href });
};
try {
await waitFor(() => documentObject.documentElement.dataset.appReady === "true", "Builder 초기화");
await waitFor(() => documentObject.documentElement.dataset.sceneValid === "true", "기본 scene 검증");
assert(documentObject.querySelector("#scene-id").value !== "single-tone", "new default scene gets a unique id");
const previewSamples = new Float32Array(2000);
for (let index = 0; index < previewSamples.length; index += 1) {
previewSamples[index] = Math.sin((2 * Math.PI * 440 * index) / 8000) * 0.12;
}
const wavBlob = encodeMonoPcm16Wav(previewSamples, 8000);
const audioFile = new windowObject.File([wavBlob], "machine.wav", { type: "audio/wav" });
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480"><rect width="640" height="480" fill="#173128"/><circle cx="420" cy="170" r="70" fill="#65f2bf"/></svg>';
const imageFile = new windowObject.File([svg], "machine.svg", { type: "image/svg+xml" });
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]);
await waitFor(
() => [...documentObject.querySelectorAll(".asset-chip__state")].some((item) => item.textContent === "연결됨"),
"오디오 hash/decode",
);
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");
const canvasRect = canvas.getBoundingClientRect();
canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
bubbles: true,
clientX: canvasRect.left + canvasRect.width * 0.75,
clientY: canvasRect.top + canvasRect.height * 0.25,
}));
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");
dispatchValue(windowObject, documentObject.querySelector("#ambient-type"), "asset", "change");
dispatchChecked(windowObject, documentObject.querySelector("#ambient-loop"), true);
await waitFor(() => documentObject.documentElement.dataset.sceneValid === "true", "편집 scene 검증");
documentObject.querySelector("#wav-button").click();
const firstWavDownload = await waitFor(
() => downloads.find((download) => download.fileName.endsWith(".preview-mix.wav")),
"첫 preview WAV",
);
const firstWav = await fetch(firstWavDownload.url).then((response) => response.blob());
const firstWavHash = await digestHex(firstWav);
await waitFor(() => !documentObject.querySelector("#export-button").disabled, "첫 JSON 저장 버튼 복구");
documentObject.querySelector("#export-button").click();
const firstJsonDownload = await waitFor(
() => downloads.find((download) => download.fileName.endsWith(".scene.json")),
"첫 Scene JSON",
);
const firstJsonText = await fetch(firstJsonDownload.url).then((response) => response.text());
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.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");
const sceneFile = new windowObject.File([firstJsonText], "roundtrip.scene.json", { type: "application/json" });
dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [sceneFile]);
await waitFor(() => documentObject.querySelector("#save-state").textContent.startsWith("Scene 불러옴"), "Scene 재로드");
await waitFor(() => documentObject.documentElement.dataset.assetsReady === "false", "재로드 후 원본 파일 요구");
dispatchFiles(windowObject, documentObject.querySelector("#audio-input"), [audioFile]);
await waitFor(
() => [...documentObject.querySelectorAll(".asset-chip")].some((chip) =>
chip.querySelector(".asset-chip__label").textContent.includes("machine.wav") &&
chip.querySelector(".asset-chip__state").textContent === "연결됨"),
"오디오 SHA 재연결",
);
dispatchFiles(windowObject, documentObject.querySelector("#image-input"), [imageFile]);
await waitFor(() => documentObject.documentElement.dataset.assetsReady === "true", "이미지 SHA 재연결");
const downloadCountBeforeSecondWav = downloads.length;
documentObject.querySelector("#wav-button").click();
const secondWavDownload = await waitFor(
() => downloads.slice(downloadCountBeforeSecondWav).find((download) => download.fileName.endsWith(".preview-mix.wav")),
"두 번째 preview WAV",
);
const secondWav = await fetch(secondWavDownload.url).then((response) => response.blob());
assertEqual(await digestHex(secondWav), firstWavHash, "round-trip preview WAV hash");
await waitFor(() => !documentObject.querySelector("#export-button").disabled, "두 번째 JSON 저장 버튼 복구");
const downloadCountBeforeSecondJson = downloads.length;
documentObject.querySelector("#export-button").click();
const secondJsonDownload = await waitFor(
() => downloads.slice(downloadCountBeforeSecondJson).find((download) => download.fileName.endsWith(".scene.json")),
"두 번째 Scene JSON",
);
const secondScene = await fetch(secondJsonDownload.url).then((response) => response.json());
assertEqual(JSON.stringify(secondScene), JSON.stringify(firstScene), "canonical scene round trip");
documentObject.querySelector("#remove-image-button").click();
await waitFor(() => documentObject.querySelector("#remove-image-button").classList.contains("is-hidden"), "image remove");
await waitFor(() => !documentObject.querySelector("#export-button").disabled, "grid-only JSON button");
const downloadCountBeforeGridJson = downloads.length;
documentObject.querySelector("#export-button").click();
const gridJsonDownload = await waitFor(
() => downloads.slice(downloadCountBeforeGridJson).find((download) => download.fileName.endsWith(".scene.json")),
"grid-only Scene JSON",
);
const gridScene = await fetch(gridJsonDownload.url).then((response) => response.json());
assertEqual(gridScene.image, undefined, "image reference removed");
const stableName = documentObject.querySelector("#scene-name").value;
const invalidFile = new windowObject.File(["{"], "invalid.scene.json", { type: "application/json" });
dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [invalidFile]);
await waitFor(() => documentObject.querySelector("#save-state").textContent.startsWith("불러오기 실패"), "invalid import error");
assertEqual(documentObject.querySelector("#scene-name").value, stableName, "invalid import preserves scene");
dispatchValue(windowObject, documentObject.querySelector("#sample-rate"), "96000", "change");
await waitFor(() => !documentObject.querySelector("#wav-button").disabled, "cancel fixture validation");
const downloadCountBeforeCancel = downloads.length;
documentObject.querySelector("#wav-button").click();
const cancelSceneFile = new windowObject.File([firstJsonText], "cancel.scene.json", { type: "application/json" });
dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [cancelSceneFile]);
await waitFor(() => documentObject.querySelector("#save-state").textContent.startsWith("Scene 불러옴"), "import during decode");
await new Promise((resolve) => setTimeout(resolve, 250));
assertEqual(downloads.length, downloadCountBeforeCancel, "cancelled decode does not create stale download");
} finally {
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
windowObject.URL.revokeObjectURL = originalRevoke;
for (const url of objectUrls) originalRevoke(url);
frame.remove();
}
});
+211
View File
@@ -0,0 +1,211 @@
import { validateResult } from "/packages/contracts/validation.js";
async function waitFor(predicate, label, timeoutMs = 30000) {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
const value = predicate();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(`${label} 대기 시간이 초과되었습니다.`);
}
function dispatchValue(windowObject, element, value, eventName = "change") {
element.value = String(value);
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
}
async function loadJson(path) {
const response = await fetch(path);
assert(response.ok, `${path} load failed`);
return response.json();
}
async function nextPaint(windowObject) {
await new Promise((resolve) => windowObject.requestAnimationFrame(() =>
windowObject.requestAnimationFrame(resolve)));
}
test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름을 완주한다", 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 iframe load failed")), {
once: true,
});
});
const windowObject = frame.contentWindow;
const documentObject = frame.contentDocument;
const downloads = [];
const objectUrls = [];
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
const originalConfirm = windowObject.confirm.bind(windowObject);
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
downloads.push({ fileName: this.download, url: this.href });
};
windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url);
try {
await waitFor(
() => documentObject.documentElement.dataset.appReady === "true",
"Acoustic Camera 초기화",
);
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true",
"기본 Camera 입력 검증",
);
const canvas = documentObject.querySelector("#scene-canvas");
await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더");
await nextPaint(windowObject);
const initialCanvas = canvas.toDataURL();
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true" &&
!documentObject.querySelector("#run-button").disabled,
"8×8 CPU 입력 검증",
);
assertEqual(documentObject.querySelector("#grid-preset").value, "custom", "custom grid preset");
assert(
documentObject.documentElement.dataset.unsavedWork.includes("Experiment"),
"edited Experiment is marked unsaved",
);
documentObject.querySelector("#run-button").click();
await waitFor(
() => documentObject.documentElement.dataset.runState === "done",
"8×8 CPU simulation",
30000,
);
assertEqual(documentObject.documentElement.dataset.runBackend, "cpu", "actual backend");
assertEqual(documentObject.documentElement.dataset.resultBandCount, "1", "result band count");
assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 9, "result metrics rendered");
assert(!documentObject.querySelector("#metric-grid").classList.contains("is-hidden"), "metrics visible");
assert(documentObject.querySelector("#result-badge").textContent.includes("CPU"), "backend badge rendered");
assert(documentObject.documentElement.dataset.unsavedWork.includes("Result"), "completed Result is unsaved");
assert(
documentObject.documentElement.dataset.unsavedWork.includes("비교 기록"),
"comparison summary is unsaved",
);
let replacementConfirmCount = 0;
windowObject.confirm = () => {
replacementConfirmCount += 1;
return false;
};
documentObject.querySelector("#run-button").click();
await waitFor(() => replacementConfirmCount === 1, "unsaved Result replacement confirmation");
assertEqual(documentObject.documentElement.dataset.runState, "done", "declined replacement keeps result");
windowObject.confirm = originalConfirm;
await nextPaint(windowObject);
assert(canvas.toDataURL() !== initialCanvas, "result heatmap changed the scene canvas");
documentObject.querySelector("#export-result-button").click();
const resultDownload = await waitFor(
() => downloads.find((download) => download.fileName.endsWith(".result.json")),
"Result JSON download",
);
const resultDocument = await fetch(resultDownload.url).then((response) => response.json());
assert(
!documentObject.documentElement.dataset.unsavedWork.includes("Result"),
"Result export clears Result dirty state",
);
const [scene, experiment] = await Promise.all([
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
experiment.scan.columns = 8;
experiment.scan.rows = 8;
experiment.beamforming.backend = "cpu";
const resultIssues = await validateResult(resultDocument, { scene, experiment });
assertEqual(resultIssues.length, 0, `downloaded Result validation: ${resultIssues
.map((issue) => `${issue.path}: ${issue.message}`)
.join(" · ")}`);
assertEqual(resultDocument.backend, "cpu", "exported backend");
assertEqual(resultDocument.bands[0].rawMap.rows, 8, "exported map rows");
assertEqual(resultDocument.bands[0].rawMap.columns, 8, "exported map columns");
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");
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");
dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 24);
await waitFor(
() => documentObject.querySelector("#result-badge").textContent.includes("이전 설정"),
"changed input stale badge",
);
assert(documentObject.querySelector("#canvas-badge").textContent.includes("이전 설정"), "canvas stale badge");
dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 30);
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true" &&
!documentObject.querySelector("#run-button").disabled,
"original comparison configuration restore",
);
dispatchValue(
windowObject,
documentObject.querySelector("#array-preset"),
"fermat-spiral-192",
);
await waitFor(
() => documentObject.querySelector("#array-title").textContent.includes("Fermat") &&
documentObject.documentElement.dataset.cameraValid === "true" &&
!documentObject.querySelector("#run-button").disabled,
"Fermat comparison array",
);
documentObject.querySelector("#run-button").click();
await waitFor(
() => documentObject.documentElement.dataset.runState === "running",
"rerun start",
);
assert(!documentObject.querySelector("#cancel-button").classList.contains("is-hidden"), "cancel available");
await waitFor(
() => documentObject.documentElement.dataset.runState === "done",
"8×8 CPU rerun",
30000,
);
assert(!documentObject.querySelector("#result-badge").textContent.includes("이전 설정"), "fresh result badge");
assertEqual(documentObject.querySelectorAll("#history-list .history-card").length, 2, "rerun history");
const historyText = documentObject.querySelector("#history-list").textContent;
assert(historyText.includes("rectangular baseline"), "baseline history retained");
assert(historyText.includes("Fermat spiral"), "comparison array history added");
documentObject.querySelector("#export-history-button").click();
const historyDownload = await waitFor(
() => downloads.find((download) => download.fileName.endsWith(".comparison-history.json")),
"comparison history download",
);
const historyDocument = await fetch(historyDownload.url).then((response) => response.json());
assertEqual(historyDocument.kind, "micarrsim.comparison-history", "comparison history kind");
assertEqual(historyDocument.items.length, 2, "both comparison summaries exported");
assert(
!documentObject.documentElement.dataset.unsavedWork.includes("비교 기록"),
"history export clears comparison dirty state",
);
documentObject.querySelector("#export-result-button").click();
documentObject.querySelector("#export-experiment-button").click();
await waitFor(
() => !documentObject.documentElement.dataset.unsavedWork.includes("Result") &&
!documentObject.documentElement.dataset.unsavedWork.includes("Experiment"),
"final Camera documents saved",
);
assertEqual(documentObject.documentElement.dataset.unsavedWork, "", "all Camera work is saved");
} finally {
windowObject.confirm = originalConfirm;
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
windowObject.URL.revokeObjectURL = originalRevoke;
for (const url of objectUrls) originalRevoke(url);
frame.remove();
}
});
+42
View File
@@ -0,0 +1,42 @@
import { computeDasMap } from "/packages/acoustics-core/das.js";
import { distance3 } from "/packages/acoustics-core/math.js";
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
async function loadJson(path) {
const response = await fetch(path);
assert(response.ok, `${path} load failed`);
return response.json();
}
test("192채널 · 50×50 CPU baseline이 ground truth를 찾는다", 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"),
]);
const startedAt = performance.now();
const synthesis = synthesizeMicrophoneSignals({ scene, arrayDefinition });
const result = await computeDasMap({
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
scene,
experiment,
});
const elapsedMs = performance.now() - startedAt;
const localizationError = distance3(result.bands[0].peak.position, scene.sources[0].position);
assertEqual(result.microphoneCount, 192, "microphone count");
assertEqual(result.pointCount, 2500, "scan point count");
assert(localizationError < 0.03, `localization error=${localizationError} m`);
assert(Number.isFinite(result.bands[0].peak.rawPower) && result.bands[0].peak.rawPower > 0, "finite peak power");
globalThis.__micarrsimCpuBaseline = {
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
scene,
experiment,
result,
};
document.documentElement.dataset.cpuBaselineMs = elapsedMs.toFixed(1);
});
+14
View File
@@ -0,0 +1,14 @@
test("로컬 서버가 숨김 경로와 쓰기 method를 차단하고 보안 header를 보낸다", async () => {
const hiddenResponse = await fetch("/.git/config");
assertEqual(hiddenResponse.status, 403, "hidden repository path");
const postResponse = await fetch("/", { method: "POST" });
assertEqual(postResponse.status, 405, "unsupported method");
assertEqual(postResponse.headers.get("allow"), "GET, HEAD", "allowed methods");
const rootResponse = await fetch("/");
assertEqual(rootResponse.status, 200, "root remains available");
assertEqual(rootResponse.headers.get("x-content-type-options"), "nosniff", "nosniff header");
assertEqual(rootResponse.headers.get("referrer-policy"), "no-referrer", "referrer policy");
assert(rootResponse.headers.get("content-security-policy")?.includes("worker-src 'self'"), "worker CSP");
});
+176
View File
@@ -0,0 +1,176 @@
import { computeDasMap } from "/packages/acoustics-core/das.js";
import { distance3 } from "/packages/acoustics-core/math.js";
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js";
import { computeDasWebGpu } from "/packages/webgpu-kernels/das.js";
function createArray(columns, rows, width, height) {
const microphones = [];
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
const index = row * columns + column;
microphones.push({
id: `gpu-mic-${row}-${column}`,
position: [
-width / 2 + (column * width) / (columns - 1),
-height / 2 + (row * height) / (rows - 1),
0,
],
sensitivityDb: (index % 3) * 2 - 2,
phaseOffsetDeg: (index % 5) * 8 - 16,
});
}
}
return { id: "gpu-test-array", aperture: { width, height }, microphones };
}
function createFixture(backend = "webgpu") {
const arrayDefinition = createArray(4, 4, 0.3, 0.3);
const scene = {
id: "gpu-das-scene",
sampleRate: 8_000,
duration: 0.064,
speedOfSound: 343,
seed: 11,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "source",
name: "source",
position: [0.1, 0.1, 1.2],
gainDb: -12,
startTime: 0,
duration: 0.064,
signal: { type: "tone", frequency: 1_000, phaseDeg: 0 },
}],
ambient: [],
};
const experiment = {
stft: { fftSize: 128, hopSize: 64, window: "hann" },
scan: { z: 1.2, xMin: -0.4, xMax: 0.4, yMin: -0.4, yMax: 0.4, columns: 9, rows: 9 },
frequencyBands: [{ id: "1k", minHz: 990, maxHz: 1_010, color: "#65f2bf" }],
beamforming: { backend, algorithm: "das", normalization: "peak" },
};
const synthesis = synthesizeMicrophoneSignals({ scene, arrayDefinition });
return {
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
scene,
experiment,
};
}
function normalizedMapError(reference, actual) {
const referenceScale = Math.max(...reference);
let squaredError = 0;
let maximumAbsoluteError = 0;
for (let index = 0; index < reference.length; index += 1) {
const error = Math.abs(actual[index] - reference[index]) / referenceScale;
squaredError += error * error;
maximumAbsoluteError = Math.max(maximumAbsoluteError, error);
}
return {
rms: Math.sqrt(squaredError / reference.length),
maximumAbsoluteError,
};
}
test("WebGPU DAS가 CPU 수치 기준과 일치하거나 auto CPU fallback한다", async () => {
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
if (!adapter) {
const fixture = createFixture("auto");
const result = await computeDasWithBackend(fixture);
assertEqual(result.backend, "cpu", "auto fallback backend without adapter");
assert(result.bands[0].peak.rawPower > 0, "fallback produced a usable map");
document.documentElement.dataset.webgpuIntegration = "cpu-fallback";
return;
}
const fixture = createFixture("webgpu");
const cpuResult = await computeDasMap(fixture);
const gpuResult = await computeDasWebGpu(fixture);
const cpuBand = cpuResult.bands[0];
const gpuBand = gpuResult.bands[0];
const error = normalizedMapError(cpuBand.rawMap, gpuBand.rawMap);
assertEqual(gpuResult.backend, "webgpu", "actual WebGPU backend");
assertEqual(gpuBand.rawMap.length, cpuBand.rawMap.length, "map size");
assert(error.rms <= 2e-3, `CPU/GPU normalized RMS error=${error.rms}`);
assert(
error.maximumAbsoluteError <= 1e-2,
`CPU/GPU normalized maximum absolute error=${error.maximumAbsoluteError}`,
);
const gridDiagonal = Math.hypot(
(fixture.experiment.scan.xMax - fixture.experiment.scan.xMin) /
(fixture.experiment.scan.columns - 1),
(fixture.experiment.scan.yMax - fixture.experiment.scan.yMin) /
(fixture.experiment.scan.rows - 1),
);
assert(
distance3(gpuBand.peak.position, cpuBand.peak.position) <= gridDiagonal + 1e-9,
"CPU/GPU peak positions are within one grid diagonal",
);
assert(
distance3(gpuBand.peak.position, fixture.scene.sources[0].position) <= 0.071,
`GPU localization error=${distance3(gpuBand.peak.position, fixture.scene.sources[0].position)} m`,
);
document.documentElement.dataset.webgpuIntegration = "gpu";
document.documentElement.dataset.webgpuRmsError = error.rms.toExponential(3);
});
test("192채널 · 50×50 WebGPU map이 CPU baseline 허용 오차를 만족한다", async () => {
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
if (!adapter) {
document.documentElement.dataset.webgpuBaseline = "unavailable";
return;
}
const baseline = globalThis.__micarrsimCpuBaseline;
assert(baseline, "CPU baseline fixture is available");
const experiment = {
...baseline.experiment,
beamforming: { ...baseline.experiment.beamforming, backend: "webgpu" },
};
const result = await computeDasWebGpu({ ...baseline, experiment });
const cpuBand = baseline.result.bands[0];
const gpuBand = result.bands[0];
const error = normalizedMapError(cpuBand.rawMap, gpuBand.rawMap);
const peakPowerRelativeError =
Math.abs(gpuBand.peak.rawPower - cpuBand.peak.rawPower) / cpuBand.peak.rawPower;
const gridDiagonal = Math.hypot(
(experiment.scan.xMax - experiment.scan.xMin) / (experiment.scan.columns - 1),
(experiment.scan.yMax - experiment.scan.yMin) / (experiment.scan.rows - 1),
);
assert(gpuBand.rawMap.every((value) => Number.isFinite(value) && value >= 0), "finite nonnegative GPU map");
assert(error.rms <= 0.005, `192ch CPU/GPU normalized RMS error=${error.rms}`);
assert(error.maximumAbsoluteError <= 0.02, `192ch CPU/GPU normalized max error=${error.maximumAbsoluteError}`);
assert(peakPowerRelativeError <= 0.02, `192ch CPU/GPU peak power relative error=${peakPowerRelativeError}`);
assert(
distance3(gpuBand.peak.position, cpuBand.peak.position) <= gridDiagonal + 1e-9,
"192ch CPU/GPU peaks are within one grid diagonal",
);
document.documentElement.dataset.webgpuBaseline = "gpu";
document.documentElement.dataset.webgpuBaselineMs = result.timingMs.total.toFixed(1);
document.documentElement.dataset.webgpuBaselineRmsError = error.rms.toExponential(3);
});
test("WebGPU bin·point 강제 batching 결과가 CPU map과 일치한다", async () => {
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
if (!adapter) return;
const fixture = createFixture("webgpu");
fixture.experiment.frequencyBands = [
{ id: "wide-1k", minHz: 900, maxHz: 1_100, color: "#65f2bf" },
];
const cpuResult = await computeDasMap(fixture);
const gpuResult = await computeDasWebGpu({
...fixture,
maximumSpectrumBatchBins: 1,
maximumSteeringBufferBytes: 512,
});
const error = normalizedMapError(cpuResult.bands[0].rawMap, gpuResult.bands[0].rawMap);
assertEqual(gpuResult.gpu.batchPlan.binBatchSize, 1, "forced bin batch size");
assertEqual(gpuResult.gpu.batchPlan.pointBatchSize, 4, "forced point batch size");
assert(error.rms <= 0.005, `chunked CPU/GPU normalized RMS error=${error.rms}`);
assert(error.maximumAbsoluteError <= 0.02, `chunked CPU/GPU max error=${error.maximumAbsoluteError}`);
});
+285
View File
@@ -0,0 +1,285 @@
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
import { validateExperiment, validateResult, validateScene } from "/packages/contracts/validation.js";
async function waitFor(predicate, label, timeoutMs = 30000) {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
const value = predicate();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(`${label} timed out.`);
}
function dispatchValue(windowObject, element, value, eventName = "input") {
element.value = String(value);
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
}
function dispatchChecked(windowObject, element, checked) {
element.checked = checked;
element.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
}
function dispatchFiles(windowObject, input, files) {
const transfer = new windowObject.DataTransfer();
files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
input.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
}
async function mountApp(path, label) {
const frame = document.createElement("iframe");
frame.src = `${path}?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(`${label} iframe load failed`)), { once: true });
});
await waitFor(
() => frame.contentDocument.documentElement.dataset.appReady === "true",
`${label} initialization`,
);
return frame;
}
function captureDownloads(windowObject) {
const downloads = [];
const objectUrls = [];
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
downloads.push({ fileName: this.download, url: this.href });
};
windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url);
return {
downloads,
restore() {
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
windowObject.URL.revokeObjectURL = originalRevoke;
for (const url of objectUrls) originalRevoke(url);
},
};
}
function makeExperiment(scene) {
const plane = scene.image.plane;
return {
schemaVersion: "1.0.0",
kind: "micarrsim.experiment",
id: "builder-camera-workflow",
name: "Builder to Camera asset workflow",
arrayId: "rectangular-16x12",
sceneId: scene.id,
scan: {
z: plane.z,
xMin: plane.xMin,
xMax: plane.xMax,
yMin: plane.yMin,
yMax: plane.yMax,
columns: 8,
rows: 8,
},
frequencyBands: [
{ id: "asset-500", minHz: 450, maxHz: 550, color: "#65f2bf" },
{ id: "tone-1k", minHz: 950, maxHz: 1050, color: "#58c7e8" },
],
stft: { fftSize: 128, hopSize: 64, window: "hann" },
beamforming: {
algorithm: "das",
backend: "cpu",
minimumDistance: 0.01,
normalization: "peak",
dynamicRangeDb: 30,
},
};
}
test("Dataset Builder exports asset Scene that Acoustic Camera rebinds and runs", async () => {
const sampleRate = 8000;
const duration = 0.05;
const samples = new Float32Array(sampleRate * duration);
for (let index = 0; index < samples.length; index += 1) {
samples[index] = Math.sin((2 * Math.PI * 500 * index) / sampleRate) * 0.2;
}
const audioBytes = await encodeMonoPcm16Wav(samples, sampleRate).arrayBuffer();
const imageBytes = new TextEncoder().encode(
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240">' +
'<rect width="320" height="240" fill="#102924"/>' +
'<circle cx="210" cy="95" r="35" fill="#65f2bf"/></svg>',
);
let builderFrame = null;
let builderCapture = null;
let cameraFrame = null;
let cameraCapture = null;
try {
builderFrame = await mountApp("/apps/dataset-builder/", "Dataset Builder");
const builderWindow = builderFrame.contentWindow;
const builderDocument = builderFrame.contentDocument;
builderCapture = captureDownloads(builderWindow);
dispatchValue(builderWindow, builderDocument.querySelector("#scene-id"), "workflow-asset-scene");
dispatchValue(builderWindow, builderDocument.querySelector("#scene-name"), "Workflow asset scene");
dispatchValue(builderWindow, builderDocument.querySelector("#scene-duration"), duration);
dispatchValue(builderWindow, builderDocument.querySelector("#source-duration"), duration);
dispatchValue(builderWindow, builderDocument.querySelector("#source-z"), 1.6);
dispatchValue(builderWindow, builderDocument.querySelector("#plane-x-min"), -0.3);
dispatchValue(builderWindow, builderDocument.querySelector("#plane-x-max"), 0.5);
dispatchValue(builderWindow, builderDocument.querySelector("#plane-y-min"), -0.25);
dispatchValue(builderWindow, builderDocument.querySelector("#plane-y-max"), 0.35);
dispatchValue(builderWindow, builderDocument.querySelector("#sample-rate"), sampleRate, "change");
const builderAudioFile = new builderWindow.File([audioBytes.slice(0)], "workflow-tone.wav", {
type: "audio/wav",
});
const builderImageFile = new builderWindow.File([imageBytes.slice(0)], "workflow-layout.svg", {
type: "image/svg+xml",
});
dispatchFiles(builderWindow, builderDocument.querySelector("#audio-input"), [builderAudioFile]);
await waitFor(
() => builderDocument.querySelectorAll(".asset-chip .asset-chip__state").length === 1 &&
builderDocument.querySelector(".asset-chip .asset-chip__state").textContent.includes("연결"),
"Builder audio SHA attachment",
);
dispatchFiles(builderWindow, builderDocument.querySelector("#image-input"), [builderImageFile]);
await waitFor(
() => builderDocument.querySelectorAll(".asset-chip .asset-chip__state").length === 2 &&
[...builderDocument.querySelectorAll(".asset-chip .asset-chip__state")]
.every((element) => element.textContent.includes("연결")),
"Builder image SHA attachment",
);
builderDocument.querySelector("#add-source-button").click();
assertEqual(builderDocument.querySelectorAll(".source-card").length, 2, "two Builder sources");
dispatchValue(builderWindow, builderDocument.querySelector("#source-name"), "Asset source");
dispatchValue(builderWindow, builderDocument.querySelector("#source-x"), -0.1);
dispatchValue(builderWindow, builderDocument.querySelector("#source-y"), -0.08);
dispatchValue(builderWindow, builderDocument.querySelector("#source-type"), "asset", "change");
dispatchChecked(builderWindow, builderDocument.querySelector("#source-loop"), true);
await waitFor(
() => builderDocument.documentElement.dataset.sceneValid === "true" &&
builderDocument.documentElement.dataset.assetsReady === "true" &&
!builderDocument.querySelector("#export-button").disabled,
"Builder asset Scene validation",
);
builderDocument.querySelector("#export-button").click();
const sceneDownload = await waitFor(
() => builderCapture.downloads.find((download) => download.fileName.endsWith(".scene.json")),
"Builder Scene JSON export",
);
const sceneJson = await fetch(sceneDownload.url).then((response) => response.text());
const scene = JSON.parse(sceneJson);
const sceneIssues = await validateScene(scene);
assertEqual(sceneIssues.length, 0, `Builder Scene validation: ${sceneIssues
.map((issue) => `${issue.path}: ${issue.message}`)
.join(" | ")}`);
assertEqual(scene.sampleRate, sampleRate, "exported Scene sample rate");
assertEqual(scene.duration, duration, "exported Scene duration");
assertEqual(scene.sources.length, 2, "exported source count");
assert(scene.sources.some((source) => source.signal.type === "tone"), "exported tone source");
assert(scene.sources.some((source) => source.signal.type === "asset"), "exported asset source");
assertEqual(scene.assets.length, 2, "exported audio and image descriptors");
assertEqual(scene.image.plane.z, 1.6, "exported image plane z");
assertEqual(scene.image.plane.xMin, -0.3, "exported image plane xMin");
assertEqual(scene.image.plane.xMax, 0.5, "exported image plane xMax");
assert(scene.assets.every((asset) => /^[a-f0-9]{64}$/.test(asset.sha256)), "exported SHA manifests");
builderCapture.restore();
builderCapture = null;
builderFrame.remove();
builderFrame = null;
cameraFrame = await mountApp("/apps/acoustic-camera/", "Acoustic Camera");
const cameraWindow = cameraFrame.contentWindow;
const cameraDocument = cameraFrame.contentDocument;
cameraCapture = captureDownloads(cameraWindow);
const sceneFile = new cameraWindow.File([sceneJson], "workflow.scene.json", {
type: "application/json",
});
dispatchFiles(cameraWindow, cameraDocument.querySelector("#scene-input"), [sceneFile]);
await waitFor(
() => cameraDocument.documentElement.dataset.inputBusy === "false" &&
cameraDocument.querySelector("#workspace-title").textContent === scene.name &&
cameraDocument.querySelectorAll("#asset-list .asset-status").length === 2,
"Camera Scene import",
);
assertEqual(cameraDocument.documentElement.dataset.cameraValid, "false", "assets required after import");
assertEqual(cameraDocument.querySelector("#scene-distance").valueAsNumber, scene.image.plane.z, "scan z follows Scene");
assertEqual(cameraDocument.querySelector("#scan-x-min").valueAsNumber, scene.image.plane.xMin, "scan xMin follows Scene");
assertEqual(cameraDocument.querySelector("#scan-x-max").valueAsNumber, scene.image.plane.xMax, "scan xMax follows Scene");
assertEqual(cameraDocument.querySelector("#scan-y-min").valueAsNumber, scene.image.plane.yMin, "scan yMin follows Scene");
assertEqual(cameraDocument.querySelector("#scan-y-max").valueAsNumber, scene.image.plane.yMax, "scan yMax follows Scene");
const experiment = makeExperiment(scene);
const arrayDefinition = await fetch("/assets/array-geometries/rectangular-16x12.array.json")
.then((response) => response.json());
const experimentIssues = await validateExperiment(experiment, { scene, arrayDefinition });
assertEqual(experimentIssues.length, 0, `workflow Experiment validation: ${experimentIssues
.map((issue) => `${issue.path}: ${issue.message}`)
.join(" | ")}`);
const experimentFile = new cameraWindow.File(
[JSON.stringify(experiment)],
"workflow.experiment.json",
{ type: "application/json" },
);
dispatchFiles(cameraWindow, cameraDocument.querySelector("#experiment-input"), [experimentFile]);
await waitFor(
() => cameraDocument.documentElement.dataset.inputBusy === "false" &&
cameraDocument.querySelector("#experiment-id").value === experiment.id &&
cameraDocument.querySelector("#scan-columns").value === "8" &&
cameraDocument.querySelector("#backend").value === "cpu",
"Camera Experiment import",
);
const cameraAudioFile = new cameraWindow.File([audioBytes.slice(0)], "workflow-tone.wav", {
type: "audio/wav",
});
const cameraImageFile = new cameraWindow.File([imageBytes.slice(0)], "workflow-layout.svg", {
type: "image/svg+xml",
});
dispatchFiles(cameraWindow, cameraDocument.querySelector("#asset-input"), [cameraAudioFile, cameraImageFile]);
await waitFor(
() => cameraDocument.documentElement.dataset.inputBusy === "false" &&
cameraDocument.documentElement.dataset.cameraValid === "true" &&
cameraDocument.querySelectorAll("#asset-list .asset-status.is-ready").length === 2 &&
!cameraDocument.querySelector("#run-button").disabled,
"Camera SHA asset rebind",
);
cameraDocument.querySelector("#run-button").click();
await waitFor(
() => cameraDocument.documentElement.dataset.runState === "done",
"Camera CPU Worker run",
30000,
);
assertEqual(cameraDocument.documentElement.dataset.runBackend, "cpu", "workflow backend");
assertEqual(cameraDocument.documentElement.dataset.resultBandCount, "2", "workflow result bands");
cameraDocument.querySelector("#export-result-button").click();
const resultDownload = await waitFor(
() => cameraCapture.downloads.find((download) => download.fileName.endsWith(".result.json")),
"Camera Result JSON export",
);
const result = await fetch(resultDownload.url).then((response) => response.json());
const resultIssues = await validateResult(result, { scene, experiment });
assertEqual(resultIssues.length, 0, `workflow Result validation: ${resultIssues
.map((issue) => `${issue.path}: ${issue.message}`)
.join(" | ")}`);
assertEqual(result.sceneId, scene.id, "Result references Builder Scene");
assertEqual(result.experimentId, experiment.id, "Result references imported Experiment");
assertEqual(result.backend, "cpu", "Result records CPU backend");
assertEqual(result.bands.length, 2, "Result records both bands");
assert(result.bands.every((band) => band.rawMap.rows === 8), "Result map rows");
assert(result.bands.every((band) => band.rawMap.columns === 8), "Result map columns");
assert(result.bands.every((band) => band.rawMap.values.length === 64), "Result map values");
} finally {
cameraCapture?.restore();
builderCapture?.restore();
cameraFrame?.remove();
builderFrame?.remove();
}
});
+69
View File
@@ -0,0 +1,69 @@
const registeredTests = [];
globalThis.test = (name, callback) => registeredTests.push({ name, callback });
globalThis.assert = (condition, message = "assertion failed") => {
if (!condition) throw new Error(message);
};
globalThis.assertEqual = (actual, expected, message = "values differ") => {
if (!Object.is(actual, expected)) throw new Error(`${message}: expected ${expected}, received ${actual}`);
};
try {
await import("/tests/unit/contracts.test.js");
await import("/tests/unit/acoustics.test.js");
await import("/tests/unit/builder.test.js");
await import("/tests/unit/evaluation.test.js");
await import("/tests/unit/camera.test.js");
await import("/tests/unit/webgpu.test.js");
await import("/tests/integration/cpu-baseline.test.js");
await import("/tests/integration/webgpu.test.js");
await import("/tests/integration/server.test.js");
await import("/tests/integration/builder-ui.test.js");
await import("/tests/integration/camera-ui.test.js");
await import("/tests/integration/workflow-ui.test.js");
} catch (error) {
registeredTests.push({
name: "테스트 모듈을 불러온다",
callback: () => {
throw error;
},
});
}
if (registeredTests.length === 0) {
registeredTests.push({
name: "하나 이상의 테스트가 등록된다",
callback: () => {
throw new Error("등록된 테스트가 없습니다.");
},
});
}
const resultList = document.querySelector("#test-results");
const summary = document.querySelector("#test-summary");
let passed = 0;
for (const registeredTest of registeredTests) {
const item = document.createElement("li");
item.className = "test-result";
try {
await registeredTest.callback();
passed += 1;
item.classList.add("test-result--pass");
item.textContent = `✓ ${registeredTest.name}`;
} catch (error) {
item.classList.add("test-result--fail");
const title = document.createElement("strong");
title.textContent = `✕ ${registeredTest.name}`;
const details = document.createElement("pre");
details.textContent = error instanceof Error ? error.stack : String(error);
item.append(title, details);
}
resultList.append(item);
}
const failed = registeredTests.length - passed;
summary.textContent = `${registeredTests.length}개 중 ${passed}개 통과, ${failed}개 실패`;
summary.dataset.failed = String(failed);
document.documentElement.dataset.testStatus = failed === 0 ? "pass" : "fail";
document.title = failed === 0 ? "PASS · micarrsim tests" : "FAIL · micarrsim tests";
+24 -1
View File
@@ -1,3 +1,26 @@
# Unit Tests
음향 수식, 좌표 변환, CPU/GPU 수치 일치 테스트를 둡니다.
브라우저에서 실행되는 결정론적 수치·계약 테스트입니다. 별도 Node.js test runner가 필요하지
않습니다.
## 실행
1. 저장소의 `start.cmd`를 실행합니다.
2. 최신 Edge 또는 Chrome에서 <http://127.0.0.1:4173/tests/>을 엽니다.
3. 페이지 상단의 통과/실패 합계와 각 테스트 결과를 확인합니다.
Unit과 integration module은 같은 browser runner에서 순서대로 실행됩니다. 다시 실행하려면 페이지를
새로 고치세요. 다른 포트를 사용했다면 URL의 포트도 바꾸며, `tests/index.html`을 파일로 직접 열면
안 됩니다.
## 범위
- `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 검증
- `webgpu.test.js`: WGSL buffer packing, bin/point batching, 장치 한계와 typed CPU fallback 정책
WebGPU unit test 대부분은 실제 GPU 없이 planner와 binary layout을 검증합니다. 실제 GPU 수치 일치는
integration test에서 adapter가 있을 때 수행합니다.
+371
View File
@@ -0,0 +1,371 @@
import { computeBinMap, computeDasMap, createDistanceMatrix } from "/packages/acoustics-core/das.js";
import { fftInPlace, fftReal } from "/packages/acoustics-core/fft.js";
import {
addFrequencyDomainDelay,
createDelayWorkspace,
prepareDelaySpectrum,
} from "/packages/acoustics-core/fractional-delay.js";
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 { getFrameCount } from "/packages/acoustics-core/stft.js";
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
function assertNear(actual, expected, tolerance, label) {
assert(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected} ± ${tolerance}, received ${actual}`);
}
function rms(values, start = 0, end = values.length) {
let sum = 0;
for (let index = start; index < end; index += 1) sum += values[index] ** 2;
return Math.sqrt(sum / (end - start));
}
function createArray(columns, rows, width, height) {
const microphones = [];
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
microphones.push({
id: `mic-${row}-${column}`,
position: [
-width / 2 + (column * width) / (columns - 1),
-height / 2 + (row * height) / (rows - 1),
0,
],
});
}
}
return { id: "test-array", aperture: { width, height }, microphones };
}
test("거리, dB와 구면 감쇠 기본식을 계산한다", () => {
assertNear(distance3([0, 0, 0], [1, 2, 2]), 3, 1e-12, "distance");
assertNear(dbToLinear(-6.020599913), 0.5, 1e-9, "dB amplitude");
assertNear(sphericalAttenuation(2), 0.5, 1e-12, "1/r attenuation");
});
test("index 기반 noise가 seed에 대해 결정적이다", () => {
const first = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
const second = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
assert(first.every((value, index) => value === second[index]));
assert(first.some((value, index) => value !== whiteNoiseAt(43, index)));
});
test("radix-2 FFT가 정확한 tone bin을 찾는다", () => {
const size = 128;
const bin = 16;
const samples = Float64Array.from({ length: size }, (_, index) => Math.sin((2 * Math.PI * bin * index) / size));
const spectrum = fftReal(samples);
const magnitude = Math.hypot(spectrum.real[bin], spectrum.imaginary[bin]);
assertNear(magnitude, size / 2, 1e-8, "tone magnitude");
assertNear(spectrum.real[bin], 0, 1e-8, "forward real sign");
assertNear(spectrum.imaginary[bin], -size / 2, 1e-8, "forward imaginary sign");
assert(Math.hypot(spectrum.real[bin - 1], spectrum.imaginary[bin - 1]) < 1e-8, "adjacent leakage");
fftInPlace(spectrum.real, spectrum.imaginary, true);
samples.forEach((value, index) => assertNear(spectrum.real[index], value, 1e-9, `inverse sample ${index}`));
});
test("FFT phase shift가 integer와 fractional delay를 적용한다", () => {
const impulse = new Float32Array(16);
impulse[2] = 1;
const impulseSpectrum = prepareDelaySpectrum(impulse, 3);
const impulseOutput = new Float32Array(16);
addFrequencyDomainDelay({
output: impulseOutput,
spectrum: impulseSpectrum,
delaySamples: 3,
workspace: createDelayWorkspace(impulseSpectrum.fftSize),
});
assertNear(impulseOutput[5], 1, 1e-6, "integer delayed impulse");
const size = 512;
const frequencyCyclesPerSample = 1 / 8;
const tone = Float32Array.from({ length: size }, (_, index) => Math.sin(2 * Math.PI * frequencyCyclesPerSample * index));
const toneSpectrum = prepareDelaySpectrum(tone, 2.5);
const delayedTone = new Float32Array(size);
addFrequencyDomainDelay({
output: delayedTone,
spectrum: toneSpectrum,
delaySamples: 2.5,
workspace: createDelayWorkspace(toneSpectrum.fftSize),
});
let squaredError = 0;
let count = 0;
for (let index = 48; index < size - 48; index += 1) {
const expected = Math.sin(2 * Math.PI * frequencyCyclesPerSample * (index - 2.5));
squaredError += (delayedTone[index] - expected) ** 2;
count += 1;
}
assert(Math.sqrt(squaredError / count) < 1e-3, "fractional delay RMS error");
});
test("STFT frame count가 마지막 partial tail을 포함한다", () => {
assertEqual(getFrameCount(512, 512, 256), 1);
assertEqual(getFrameCount(513, 512, 256), 2);
assertEqual(getFrameCount(12000, 512, 256), 46);
});
test("구면 전파가 거리별 지연과 1/r 진폭비를 적용한다", () => {
const scene = {
sampleRate: 8000,
duration: 0.02,
speedOfSound: 400,
seed: 1,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "tone", name: "tone", position: [0, 0, 0.1], gainDb: -40, startTime: 0, duration: 0.02,
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
}],
ambient: [],
};
const arrayDefinition = {
microphones: [
{ id: "near", position: [0, 0, 0.05] },
{ id: "far", position: [0, 0, 0] },
],
};
const result = synthesizeMicrophoneSignals({ scene, arrayDefinition });
const emittedSampleOne = Math.sin((2 * Math.PI * 500) / 8000) * dbToLinear(-40);
assertNear(result.channels[0][1], 0, 1e-7, "one-sample propagation delay");
assertNear(result.channels[0][2], emittedSampleOne * 20, 1e-5, "near analytic delayed sample");
assertNear(result.channels[1][3], emittedSampleOne * 10, 1e-5, "far analytic delayed sample");
assertNear(rms(result.channels[0], 16, 150) / rms(result.channels[1], 16, 150), 2, 0.02, "distance amplitude ratio");
});
test("peak mix 정규화와 channel buffer 한도를 적용한다", () => {
const scene = {
sampleRate: 8000,
duration: 0.02,
speedOfSound: 343,
seed: 1,
mix: { normalization: "peak", targetPeak: 0.5 },
sources: [{
id: "tone", name: "tone", position: [0, 0, 1], gainDb: 6, startTime: 0, duration: 0.02,
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
}],
ambient: [],
};
const arrayDefinition = { microphones: [{ id: "mic", position: [0, 0, 0] }] };
const result = synthesizeMicrophoneSignals({ scene, arrayDefinition });
assertNear(result.peak, 0.5, 1e-6, "normalized peak");
assert(result.appliedScale < 1);
let error;
try {
synthesizeMicrophoneSignals({ scene, arrayDefinition, maximumBufferBytes: 1 });
} catch (caught) {
error = caught;
}
assert(error instanceof Error && error.message.includes("한도"), "buffer limit error");
});
test("ambient에도 microphone gain과 phase calibration 편차를 적용한다", () => {
const scene = {
sampleRate: 8000,
duration: 0.02,
speedOfSound: 343,
seed: 1,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [],
ambient: [{ id: "ambient", gainDb: -20, signal: { type: "tone", frequency: 500, phaseDeg: 0 } }],
};
const arrayDefinition = {
microphones: [{ id: "calibrated", position: [0, 0, 0], sensitivityDb: 6, phaseOffsetDeg: 90 }],
};
const result = synthesizeMicrophoneSignals({ scene, arrayDefinition });
const expected = dbToLinear(-20) * dbToLinear(6);
assertNear(result.channels[0][80], expected, 4e-3, "ambient calibrated sample");
});
test("CPU DAS peak가 calibration 후 단일 tone의 실제 grid 위치를 찾는다", async () => {
const arrayDefinition = createArray(4, 4, 0.3, 0.3);
arrayDefinition.microphones.forEach((microphone, index) => {
microphone.sensitivityDb = (index % 3) * 2 - 2;
microphone.phaseOffsetDeg = (index % 5) * 8 - 16;
});
const scene = {
id: "das-scene",
sampleRate: 8000,
duration: 0.128,
speedOfSound: 343,
seed: 7,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "source", name: "source", position: [0.1, 0.1, 1.5], gainDb: -12, startTime: 0, duration: 0.128,
signal: { type: "tone", frequency: 1000, phaseDeg: 0 },
}],
ambient: [],
};
const experiment = {
stft: { fftSize: 256, hopSize: 128, window: "hann" },
scan: { z: 1.5, xMin: -0.4, xMax: 0.4, yMin: -0.4, yMax: 0.4, columns: 17, rows: 17 },
frequencyBands: [{ id: "1k", minHz: 990, maxHz: 1010, color: "#65f2bf" }],
beamforming: { normalization: "peak" },
};
const synthesis = synthesizeMicrophoneSignals({ scene, arrayDefinition });
const progressEvents = [];
const result = await computeDasMap({
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
scene,
experiment,
onProgress: (progress) => progressEvents.push(progress),
});
const peakPosition = result.bands[0].peak.position;
assert(distance3(peakPosition, scene.sources[0].position) <= 0.071, `peak=${peakPosition.join(",")}`);
assert(result.bands[0].rawMap !== result.bands[0].displayMap, "raw/display buffers are separate");
assertNear(Math.max(...result.bands[0].rawMap), result.bands[0].peak.rawPower, 1e-4, "raw map peak");
assertNear(Math.max(...result.bands[0].displayMap), 1, 1e-6, "normalized display peak");
assert(progressEvents.some(({ stage }) => stage === "beamforming"), "beamforming progress");
});
test("독립 복소 spectrum fixture에서 DAS steering 부호가 맞다", async () => {
const arrayDefinition = {
microphones: [
{ id: "left", position: [-0.15, 0, 0] },
{ id: "middle", position: [-0.02, 0, 0] },
{ id: "right", position: [0.12, 0, 0] },
],
};
const scan = { z: 1, xMin: -0.2, xMax: 0.2, yMin: 0, yMax: 0, columns: 2, rows: 1 };
const target = [0.2, 0, 1];
const sampleRate = 8000;
const fftSize = 256;
const bin = 32;
const frequency = (bin * sampleRate) / fftSize;
const speedOfSound = 343;
const spectrum = { real: new Float32Array(3), imaginary: new Float32Array(3) };
arrayDefinition.microphones.forEach((microphone, index) => {
const phase = (2 * Math.PI * frequency * distance3(target, microphone.position)) / speedOfSound;
spectrum.real[index] = Math.cos(-phase);
spectrum.imaginary[index] = Math.sin(-phase);
});
const map = await computeBinMap({
bin,
spectrum,
frameCount: 1,
arrayDefinition,
pointCount: 2,
distances: createDistanceMatrix(arrayDefinition, scan),
sampleRate,
fftSize,
speedOfSound,
pointBatchSize: 64,
});
assertNear(map[1], 1, 1e-6, "target coherent power");
assert(map[1] > map[0] * 1.1, `wrong-sign alternative power=${map[0]}`);
});
test("CPU DAS가 event-loop 취소와 working-set 한도를 지킨다", async () => {
const arrayDefinition = createArray(2, 2, 0.2, 0.2);
const scene = { speedOfSound: 343 };
const experiment = {
stft: { fftSize: 128, hopSize: 64, window: "hann" },
scan: { z: 1, xMin: -0.2, xMax: 0.2, yMin: -0.2, yMax: 0.2, columns: 17, rows: 17 },
frequencyBands: [{ id: "tone", minHz: 490, maxHz: 510, color: "#65f2bf" }],
beamforming: { normalization: "peak" },
};
const channels = arrayDefinition.microphones.map(() => new Float32Array(512));
let memoryError;
try {
await computeDasMap({ channels, sampleRate: 8000, arrayDefinition, scene, experiment, maximumWorkingBytes: 1 });
} catch (error) {
memoryError = error;
}
assert(memoryError instanceof Error && memoryError.message.includes("working set"), "working-set guard");
const controller = new AbortController();
let stftStarted = false;
let abortScheduled = false;
const pending = computeDasMap({
channels,
sampleRate: 8000,
arrayDefinition,
scene,
experiment,
signal: controller.signal,
pointBatchSize: 1,
stftTransformBatchSize: 1,
onProgress: (progress) => {
if (progress.stage === "stft" && progress.completedTransforms > 0 && !abortScheduled) {
stftStarted = true;
abortScheduled = true;
setTimeout(() => controller.abort(), 0);
}
},
});
let abortError;
try {
await pending;
} catch (error) {
abortError = error;
}
assert(stftStarted, "STFT started before abort");
assert(abortError?.name === "AbortError", "async abort");
});
test("scene mix가 peak 정규화와 clipping 통계를 정확히 기록한다", () => {
const assetBuffers = new Map([
["levels", { sampleRate: 4, channels: [Float32Array.of(2, -1.5, 1, -0.5)] }],
]);
const scene = {
sampleRate: 4,
duration: 1,
seed: 1,
mix: { normalization: "peak", targetPeak: 0.5 },
sources: [{
id: "levels",
gainDb: 0,
startTime: 0,
duration: 1,
signal: { type: "asset", assetId: "levels" },
}],
ambient: [],
};
const normalized = renderSceneMix(scene, assetBuffers);
assertNear(normalized.preNormalizationPeak, 2, 1e-12, "pre-normalization peak");
assertNear(normalized.appliedScale, 0.25, 1e-12, "normalization scale");
assertNear(normalized.peak, 0.5, 1e-12, "normalized peak");
assertEqual(normalized.clippedSampleCount, 0, "normalized clipping count");
[0.5, -0.375, 0.25, -0.125].forEach((expected, index) => {
assertNear(normalized.samples[index], expected, 1e-12, `normalized sample ${index}`);
});
const unnormalized = renderSceneMix({ ...scene, mix: { normalization: "none", targetPeak: 0.5 } }, assetBuffers);
assertNear(unnormalized.preNormalizationPeak, 2, 1e-12, "unnormalized input peak");
assertNear(unnormalized.appliedScale, 1, 1e-12, "unnormalized scale");
assertNear(unnormalized.peak, 2, 1e-12, "unnormalized output peak");
assertEqual(unnormalized.clippedSampleCount, 2, "unnormalized clipping count");
});
test("PCM16 WAV가 표준 mono header를 쓰고 범위 밖 sample을 clamp한다", async () => {
const blob = encodeMonoPcm16Wav(Float32Array.of(-2, -1, -0.5, 0, 0.5, 1, 2), 8000);
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
const ascii = (offset, length) => String.fromCharCode(...bytes.subarray(offset, offset + length));
assertEqual(blob.type, "audio/wav", "WAV MIME type");
assertEqual(buffer.byteLength, 58, "WAV byte length");
assertEqual(ascii(0, 4), "RIFF", "RIFF id");
assertEqual(view.getUint32(4, true), 50, "RIFF chunk size");
assertEqual(ascii(8, 4), "WAVE", "WAVE id");
assertEqual(ascii(12, 4), "fmt ", "fmt id");
assertEqual(view.getUint32(16, true), 16, "PCM fmt chunk size");
assertEqual(view.getUint16(20, true), 1, "PCM format");
assertEqual(view.getUint16(22, true), 1, "mono channel count");
assertEqual(view.getUint32(24, true), 8000, "sample rate");
assertEqual(view.getUint32(28, true), 16000, "byte rate");
assertEqual(view.getUint16(32, true), 2, "block align");
assertEqual(view.getUint16(34, true), 16, "bits per sample");
assertEqual(ascii(36, 4), "data", "data id");
assertEqual(view.getUint32(40, true), 14, "PCM data byte length");
const actualSamples = Array.from({ length: 7 }, (_, index) => view.getInt16(44 + index * 2, true));
const expectedSamples = [-32768, -32768, -16384, 0, 16383, 32767, 32767];
expectedSamples.forEach((expected, index) => {
assertEqual(actualSamples[index], expected, `PCM sample ${index}`);
});
});
+170
View File
@@ -0,0 +1,170 @@
import { coordinateToPixel, pixelToCoordinate } from "/apps/dataset-builder/canvas-view.js";
import {
assetDescriptorForFile,
createSceneId,
createSource,
matchAssetDescriptor,
prepareSceneForEditing,
referencedAssetIds,
} from "/apps/dataset-builder/model.js";
import { decodeAudioFile } from "/packages/audio-io/browser-audio.js";
import {
MAX_MIX_SAMPLE_OPERATIONS,
MAX_MIX_WORKING_BYTES,
renderSceneMix,
} from "/packages/acoustics-core/mixdown.js";
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
function assertNear(actual, expected, tolerance, label) {
assert(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected}, received ${actual}`);
}
test("Builder model이 schema-safe하고 안정적인 source/asset id를 만든다", () => {
const original = {
id: "scene",
sampleRate: 8000,
duration: 1,
sources: [{ id: "source-1", position: [0, 0, 2] }],
};
const scene = prepareSceneForEditing(original);
const added = createSource(scene);
assertEqual(added.id, "source-2", "unique source id");
assertEqual(original.assets, undefined, "input remains immutable");
assertEqual(scene.mix.targetPeak, 0.95, "default target peak");
const descriptor = assetDescriptorForFile(
{ name: "기계음 샘플.WAV", type: "audio/wav" },
"audio",
new Set(["audio"]),
);
assert(/^[a-z0-9][a-z0-9._-]*$/.test(descriptor.id), "schema-safe asset id");
assertEqual(descriptor.id, "audio-2", "collision suffix");
assertEqual(createSceneId(123456, 789), "scene-2n9c-lx", "deterministic scene id");
assert(/^[a-z0-9][a-z0-9._-]*$/.test(createSceneId()), "generated scene id is schema-safe");
});
test("asset 재연결은 fileName보다 SHA-256을 우선하고 충돌을 분리한다", () => {
const assets = [
{ id: "old", kind: "audio", fileName: "audio/machine.wav", sha256: "a".repeat(64) },
{ id: "unhashed", kind: "audio", fileName: "ambient.wav" },
];
const hashMatch = matchAssetDescriptor(assets, new Set(), "renamed.wav", "audio", "a".repeat(64));
assertEqual(hashMatch.descriptor.id, "old", "hash match");
const nameMatch = matchAssetDescriptor(assets, new Set(), "ambient.wav", "audio", "b".repeat(64));
assertEqual(nameMatch.descriptor.id, "unhashed", "unhashed name match");
const conflict = matchAssetDescriptor(assets, new Set(), "machine.wav", "audio", "c".repeat(64));
assertEqual(conflict.descriptor, null, "different hash does not reconnect");
assertEqual(conflict.conflict, true, "same-name conflict reported");
const duplicateHashAssets = [
{ id: "copy-1", kind: "audio", fileName: "copy-1.wav", sha256: "d".repeat(64) },
{ id: "copy-2", kind: "audio", fileName: "copy-2.wav", sha256: "d".repeat(64) },
];
const secondCopy = matchAssetDescriptor(duplicateHashAssets, new Set(["copy-1"]), "same.wav", "audio", "d".repeat(64));
assertEqual(secondCopy.descriptor.id, "copy-2", "unbound duplicate hash descriptor");
});
test("Builder가 image/source/ambient에서 참조하는 asset을 모두 찾는다", () => {
const ids = referencedAssetIds({
image: { assetId: "image" },
sources: [
{ signal: { type: "asset", assetId: "machine" } },
{ signal: { type: "tone", frequency: 1000 } },
],
ambient: [{ signal: { type: "asset", assetId: "ambient" } }],
});
assertEqual([...ids].join(","), "image,machine,ambient", "referenced assets");
});
test("canvas 좌표 변환이 y-up 평면의 중앙과 모서리를 왕복한다", () => {
const plane = { xMin: -2, xMax: 2, yMin: -1, yMax: 3 };
const topLeft = pixelToCoordinate({ x: 0, y: 0 }, plane, 400, 200);
assertEqual(topLeft.x, -2, "left is xMin");
assertEqual(topLeft.y, 3, "top is yMax");
const bottomRight = pixelToCoordinate({ x: 400, y: 200 }, plane, 400, 200);
assertEqual(bottomRight.x, 2, "right is xMax");
assertEqual(bottomRight.y, -1, "bottom is yMin");
const pixel = coordinateToPixel([0.75, 1.25, 2], plane, 400, 200);
const roundTrip = pixelToCoordinate(pixel, plane, 400, 200);
assertNear(roundTrip.x, 0.75, 1e-12, "x round trip");
assertNear(roundTrip.y, 1.25, 1e-12, "y round trip");
});
test("확인용 mix가 과도한 메모리와 sample 연산을 allocation 전에 거부한다", () => {
const signal = { type: "tone", frequency: 1000 };
const largeMemory = {
sampleRate: 192000,
duration: 300,
seed: 1,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [{ id: "source", gainDb: 0, startTime: 0, duration: 300, signal }],
ambient: [],
};
let memoryError;
try { renderSceneMix(largeMemory); } catch (error) { memoryError = error; }
assert(memoryError?.message.includes("작업 메모리"), "memory guard message");
assert(MAX_MIX_WORKING_BYTES < 192000 * 300 * 8, "fixture exceeds memory guard");
const sources = Array.from({ length: 18 }, (_, index) => ({
id: `source-${index}`, gainDb: 0, startTime: 0, duration: 300, signal,
}));
const largeOperations = { ...largeMemory, sampleRate: 48000, sources };
let operationError;
try { renderSceneMix(largeOperations); } catch (error) { operationError = error; }
assert(operationError?.message.includes("연산량"), "operation guard message");
assert(MAX_MIX_SAMPLE_OPERATIONS < 48000 * 300 * sources.length, "fixture exceeds operation guard");
});
test("WAV encoder가 잘못된 sample rate와 비유한 sample을 거부한다", () => {
let rateError;
try { encodeMonoPcm16Wav(Float32Array.of(0), 0); } catch (error) { rateError = error; }
assert(rateError?.message.includes("sample rate"), "sample-rate guard");
let sampleError;
try { encodeMonoPcm16Wav(Float32Array.of(0, Number.NaN), 48000); } catch (error) { sampleError = error; }
assert(sampleError?.message.includes("유한한"), "finite-sample guard");
});
test("브라우저 audio decoder가 caller의 누적 메모리 budget을 지킨다", async () => {
const wav = encodeMonoPcm16Wav(Float32Array.of(0, 0.25, -0.25, 0), 8000);
const file = new File([wav], "tiny.wav", { type: "audio/wav" });
let budgetError;
try { await decodeAudioFile(file, 8000, 8); } catch (error) { budgetError = error; }
assert(budgetError?.message.includes("decode 크기"), "decoded-byte budget error");
const header = new ArrayBuffer(44);
const view = new DataView(header);
const write = (offset, text) => [...text].forEach((character, index) => view.setUint8(offset + index, character.charCodeAt(0)));
const channels = 4;
const sampleRate = 48000;
const dataByteLength = sampleRate * 300 * channels * 2;
write(0, "RIFF"); view.setUint32(4, 36 + dataByteLength, true); write(8, "WAVE"); write(12, "fmt ");
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, channels, true);
view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * channels * 2, true);
view.setUint16(32, channels * 2, true); view.setUint16(34, 16, true); write(36, "data");
view.setUint32(40, dataByteLength, true);
const multichannelFile = new File([header], "four-channel.wav", { type: "audio/wav" });
let channelError;
try { await decodeAudioFile(multichannelFile, sampleRate); } catch (error) { channelError = error; }
assert(channelError?.message.includes("예상 decode 크기 219.7 MiB"), "WAV channel-count preflight");
});
test("Builder mix Worker가 기본 tone 장면을 별도 thread에서 생성한다", async () => {
const response = await fetch("/assets/sample-scenes/single-tone.scene.json");
const scene = await response.json();
const result = await new Promise((resolve, reject) => {
const worker = new Worker("/apps/dataset-builder/mix-worker.js", { type: "module" });
worker.addEventListener("message", ({ data }) => {
worker.terminate();
if (data.ok) resolve(data.result);
else reject(new Error(data.message));
});
worker.addEventListener("error", (event) => {
worker.terminate();
reject(new Error(event.message));
});
worker.postMessage({ scene, assetBuffers: [] });
});
assertEqual(result.samples.length, 12000, "worker sample count");
assert(result.peak > 0.5 && result.peak < 0.502, "worker tone peak");
assertEqual(result.clippedSampleCount, 0, "worker clipping count");
});
+245
View File
@@ -0,0 +1,245 @@
import {
alignExperimentToScene,
canonicalStringify,
createResultDocument,
estimateExperimentWorkload,
hashSimulationInputs,
shiftSceneDistance,
} from "/apps/acoustic-camera/model.js";
import { validateResult } from "/packages/contracts/validation.js";
function assertNear(actual, expected, tolerance, label) {
assert(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
);
}
test("Camera input hash is canonical across object key order", async () => {
const first = {
arrayDefinition: {
id: "array",
microphones: [
{ id: "mic-1", position: [0, 0, 0] },
{ id: "mic-2", position: [0.1, 0, 0] },
],
},
scene: {
id: "scene",
sampleRate: 8000,
sources: [{ id: "source", position: [0, 0, 2], signal: { type: "tone", frequency: 1000 } }],
},
experiment: {
id: "experiment",
scan: { z: 2, columns: 8, rows: 8 },
frequencyBands: [{ id: "band", minHz: 900, maxHz: 1100 }],
},
};
const reordered = {
experiment: {
frequencyBands: [{ maxHz: 1100, minHz: 900, id: "band" }],
scan: { rows: 8, columns: 8, z: 2 },
id: "experiment",
},
scene: {
sources: [{ signal: { frequency: 1000, type: "tone" }, position: [0, 0, 2], id: "source" }],
sampleRate: 8000,
id: "scene",
},
arrayDefinition: {
microphones: [
{ position: [0, 0, 0], id: "mic-1" },
{ position: [0.1, 0, 0], id: "mic-2" },
],
id: "array",
},
};
assertEqual(canonicalStringify({ z: undefined, b: 2, a: { d: 4, c: 3 } }), '{"a":{"c":3,"d":4},"b":2}', "canonical JSON");
const firstHash = await hashSimulationInputs(first);
const reorderedHash = await hashSimulationInputs(reordered);
assertEqual(firstHash, reorderedHash, "key ordering does not alter hash");
assert(/^[a-f0-9]{64}$/.test(firstHash), "hash is lowercase SHA-256");
reordered.scene.sources[0].position[0] = 0.01;
const changedHash = await hashSimulationInputs(reordered);
assert(changedHash !== firstHash, "semantic input change alters hash");
});
test("Camera workload counts unique overlapping FFT bins", () => {
const arrayDefinition = {
microphones: Array.from({ length: 3 }, (_, index) => ({ id: `mic-${index}`, position: [index, 0, 0] })),
};
const scene = { sampleRate: 8000, duration: 1 };
const experiment = {
scan: { columns: 10, rows: 8 },
stft: { fftSize: 256, hopSize: 128 },
frequencyBands: [
{ id: "lower", minHz: 100, maxHz: 200 },
{ id: "upper", minHz: 187.5, maxHz: 250 },
],
};
const workload = estimateExperimentWorkload(arrayDefinition, scene, experiment);
assertEqual(workload.pointCount, 80, "scan point count");
assertEqual(workload.microphoneCount, 3, "microphone count");
assertEqual(workload.frameCount, 62, "STFT frame count");
assertEqual(JSON.stringify(workload.bandBins), JSON.stringify([[4, 5, 6], [6, 7, 8]]), "bins per band");
assertEqual(workload.selectedBinCount, 5, "overlapping bin is counted once");
assertEqual(workload.complexMacCount, 74_400, "estimated complex MAC count");
assertNear(workload.scalingVs50, 80 / 2500, 1e-15, "50x50 scaling");
assertNear(workload.distanceMiB, (80 * 3 * 4) / 1024 ** 2, 1e-15, "distance table size");
});
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: "two", position: [-0.3, 0.4, 2.5] },
],
image: { plane: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1 } },
};
const experiment = { scan: { z: 2 } };
shiftSceneDistance(scene, experiment, 3.25);
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[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");
});
test("Camera aligns an experiment to an imported scene without changing grid resolution", () => {
const experiment = {
scan: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, columns: 32, rows: 24 },
};
const imageScene = {
sources: [{ id: "one", position: [0.2, -0.1, 4.5] }],
image: { plane: { z: 4.5, xMin: -2, xMax: 3, yMin: -1.5, yMax: 2.5 } },
};
assertEqual(alignExperimentToScene(experiment, imageScene), "image-plane", "alignment source");
assertEqual(
JSON.stringify(experiment.scan),
JSON.stringify({
z: 4.5,
xMin: -2,
xMax: 3,
yMin: -1.5,
yMax: 2.5,
columns: 32,
rows: 24,
}),
"image plane becomes scan plane while rows and columns stay unchanged",
);
const sourceScene = {
sources: [
{ id: "one", position: [-0.5, 0.1, 3] },
{ id: "two", position: [0.5, 0.2, 5] },
],
};
assertEqual(alignExperimentToScene(experiment, sourceScene), "source-bounds", "source fallback");
assertEqual(experiment.scan.z, 4, "median source depth");
assertNear(experiment.scan.xMin, -0.75, 1e-12, "source x minimum with padding");
assertNear(experiment.scan.xMax, 0.75, 1e-12, "source x maximum with padding");
assertNear(experiment.scan.yMin, -0.15, 1e-12, "source y minimum with padding");
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");
});
test("Camera result document is schema-safe and linked to its snapshot", async () => {
const arrayDefinition = {
id: "test-array",
name: "Test array",
microphones: [
{ id: "mic-1", position: [0, 0, 0] },
{ id: "mic-2", position: [0.1, 0, 0] },
],
};
const scene = {
id: "test-scene",
sources: [{ id: "source-1", position: [0, 0, 2] }],
};
const experiment = {
id: "test-experiment",
arrayId: arrayDefinition.id,
sceneId: scene.id,
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
beamforming: { backend: "auto" },
frequencyBands: [{ id: "band-1", color: "#65f2bf" }],
};
const run = {
backend: "cpu",
signalStats: { preNormalizationPeak: 0.75, appliedScale: 1, clippedSampleCount: 0 },
timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 },
fallback: { code: "webgpu-unavailable", message: "CPU fallback" },
bands: [{
id: "band-1",
color: "#65f2bf",
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
displayScale: "normalized",
}],
};
const evaluations = new Map([["band-1", {
peaks: [{
index: 3,
sourceId: "source-1",
position: [1, -1, 2],
value: 1,
localizationErrorM: 0.05,
localizationErrorPixels: 0.1,
}],
metrics: {
peakToSidelobeDb: 10,
energyInsideTruthRatio: 0.75,
precision: 1,
recall: 1,
mainLobeWidthM: 0.2,
ignoredNonFinite: Number.NaN,
},
evaluation: {
version: "1.0.0",
truthRadiusM: 0.1,
matchToleranceM: 0.2,
detectionThresholdRatio: 0.5,
detectionThresholdValue: 0.25,
minimumPeakDistanceM: 0.1,
sidelobeExclusionRadiusM: 0.1,
halfPowerRatio: 0.5,
dynamicRangeDb: 30,
maximumDetections: 128,
},
}]]);
const result = createResultDocument({
arrayDefinition,
scene,
experiment,
run,
evaluations,
inputHash: "a".repeat(64),
createdAt: "2026-08-28T00:00:00.000Z",
});
assert(Array.isArray(result.bands[0].rawMap.values), "typed raw map becomes JSON array");
assert(!("index" in result.bands[0].peaks[0]), "internal peak index is omitted");
assertEqual(
Object.keys(result.bands[0].metrics).join(","),
"peakToSidelobeDb,energyInsideTruthRatio,precision,recall,mainLobeWidthM",
"only finite schema metrics are exported",
);
assertEqual(result.bands[0].peaks[0].localizationErrorPixels, 0.1, "pixel error is exported");
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");
const issues = await validateResult(result, { experiment, scene });
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
const wrongExperiment = { ...experiment, arrayId: "another-array" };
const linkageIssues = await validateResult(result, { experiment: wrongExperiment, scene });
assert(linkageIssues.some(({ path }) => path === "$.arrayId"), "validator catches snapshot linkage mismatch");
});
+147
View File
@@ -0,0 +1,147 @@
import {
validateArrayDefinition,
validateExperiment,
validateResult,
validateScene,
} from "/packages/contracts/validation.js";
async function loadJson(path) {
const response = await fetch(path);
assert(response.ok, `${path} load failed`);
return response.json();
}
test("192채널 기준 배열이 계약을 통과한다", async () => {
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
assertEqual(arrayDefinition.microphones.length, 192, "microphone count");
assertEqual(arrayDefinition.name, "16x12 rectangular baseline", "fixture encoding");
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "array issues");
});
test("192채널 Fermat spiral 비교 배열이 계약과 원형 aperture를 만족한다", async () => {
const arrayDefinition = await loadJson("/assets/array-geometries/fermat-spiral-192.array.json");
assertEqual(arrayDefinition.microphones.length, 192, "spiral microphone count");
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "spiral array issues");
const maximumRadius = Math.max(
...arrayDefinition.microphones.map(({ position }) => Math.hypot(position[0], position[1])),
);
assert(maximumRadius <= 0.225001, `spiral aperture radius: ${maximumRadius}`);
assertEqual(
new Set(arrayDefinition.microphones.map(({ position }) => position.join(","))).size,
192,
"spiral positions are unique",
);
});
test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
assertEqual((await validateScene(scene)).length, 0, "scene issues");
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
});
test("중복 마이크 id를 거부한다", async () => {
const arrayDefinition = {
schemaVersion: "1.0.0", kind: "micarrsim.array", id: "duplicate-test", name: "duplicate test", units: "m",
coordinateSystem: { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" },
aperture: { width: 1, height: 1 },
microphones: [
{ id: "mic-1", position: [0, 0, 0] },
{ id: "mic-1", position: [0.1, 0, 0] },
],
};
assert((await validateArrayDefinition(arrayDefinition)).some(({ message }) => message.includes("중복")));
});
test("Nyquist를 넘는 tone을 거부한다", async () => {
const scene = {
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
sampleRate: 8000, duration: 1, speedOfSound: 343, seed: 1, mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "source", name: "source", position: [0, 0, 1], gainDb: 0, startTime: 0, duration: 1,
signal: { type: "tone", frequency: 5000 },
}],
};
assert((await validateScene(scene)).some(({ path }) => path.endsWith("frequency")));
});
test("NaN 좌표를 거부한다", async () => {
const arrayDefinition = {
schemaVersion: "1.0.0", kind: "micarrsim.array", id: "nan-test", name: "NaN test", units: "m",
coordinateSystem: { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" },
aperture: { width: 1, height: 1 }, microphones: [{ id: "mic-1", position: [Number.NaN, 0, 0] }],
};
assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("[0]")));
});
test("JSON Schema의 누락 필드와 범위·알 수 없는 필드를 거부한다", 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"),
]);
arrayDefinition.microphones[0].phaseOffsetDeg = 999;
scene.unexpected = true;
delete experiment.beamforming.normalization;
experiment.beamforming.dynamicRangeDb = 4;
assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("phaseOffsetDeg")));
assert((await validateScene(scene)).some(({ path }) => path === "$.unexpected"));
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
assert(experimentIssues.some(({ path }) => path.endsWith("normalization")));
assert(experimentIssues.some(({ path }) => path.endsWith("dynamicRangeDb")));
});
test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => {
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
scene.assets = [
{ id: "audio", kind: "audio", fileName: "../outside.wav", mimeType: "audio/wav" },
{ id: "image", kind: "image", fileName: "machine.png", mimeType: "image/png" },
];
scene.image = {
assetId: "audio",
widthPixels: 600,
heightPixels: 400,
plane: { z: 2, xMin: -0.6, xMax: 0.6, yMin: -0.4, yMax: 0.4 },
};
scene.sources[0].signal = { type: "asset", assetId: "image" };
const issues = await validateScene(scene);
assert(issues.some(({ path }) => path.endsWith("fileName")));
assertEqual(issues.filter(({ message }) => message.includes("asset을 참조")).length, 2);
});
test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", async () => {
const [scene, experiment] = await Promise.all([
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
const result = {
schemaVersion: "1.0.0",
kind: "micarrsim.result",
experimentId: experiment.id,
arrayId: experiment.arrayId,
sceneId: experiment.sceneId,
createdAt: "2026-08-28T00:00:00.000Z",
backend: "cpu",
inputHash: "a".repeat(64),
signalStats: { preNormalizationPeak: 0.5, appliedScale: 1, clippedSampleCount: 0 },
timingMs: { total: 10, signalGeneration: 3, beamforming: 7 },
bands: [{
id: "tone-1k",
color: "#65f2bf",
rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) },
peaks: [{ sourceId: "source-1", position: [0.08, 0.04, 2], value: 1, localizationErrorM: 0 }],
metrics: { energyInsideTruthRatio: 1, precision: 1, recall: 1 },
}],
};
assertEqual((await validateResult(result, { experiment, scene })).length, 0, "valid result issues");
result.bands[0].rawMap.values.pop();
result.bands[0].rawMap.scale = "normalized";
result.bands[0].peaks[0].sourceId = "missing-source";
const issues = await validateResult(result, { experiment, scene });
assert(issues.some(({ message }) => message.includes("rows × columns")));
assert(issues.some(({ path }) => path.endsWith("rawMap.scale")));
assert(issues.some(({ path }) => path.endsWith("sourceId")));
});
+200
View File
@@ -0,0 +1,200 @@
import {
RESULT_METRIC_KEYS,
evaluateDasBand,
exportBandEvaluation,
findLocalPeaks,
measureMainLobe,
selectResultMetrics,
} from "/packages/acoustics-core/evaluation.js";
function assertNear(actual, expected, tolerance, label) {
assert(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
);
}
function captureError(callback) {
try {
callback();
} catch (error) {
return error;
}
return undefined;
}
test("evaluation이 row-major 첫 행을 yMax로 해석한다", () => {
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2, columns: 3, rows: 3 };
const rawMap = Float32Array.of(
4, 0, 0,
0, 0, 0,
0, 0, 1,
);
const evaluation = evaluateDasBand({
rawMap,
scan,
sources: [{ id: "top-left", position: [-1, 1, 9] }],
truthRadiusM: 0.1,
matchToleranceM: 0.1,
sidelobeExclusionRadiusM: 0.1,
detectionThresholdRatio: 0.5,
minimumPeakDistanceM: 0,
});
assertEqual(evaluation.globalPeak.index, 0, "row-major global index");
assertEqual(evaluation.globalPeak.position.join(","), "-1,1,2", "first row position");
assertEqual(evaluation.sourceMatches[0].sourceId, "top-left", "source match id");
assertNear(evaluation.sourceMatches[0].localizationErrorM, 0, 1e-12, "projected localization error");
assertNear(evaluation.metrics.energyInsideTruthRatio, 0.8, 1e-12, "truth energy ratio");
assertNear(evaluation.metrics.peakToSidelobeDb, 10 * Math.log10(4), 1e-12, "peak-to-sidelobe");
assertEqual(evaluation.metrics.precision, 1, "single-source precision");
assertEqual(evaluation.metrics.recall, 1, "single-source recall");
});
test("복수 source와 threshold detection을 one-to-one matching한다", () => {
const scan = { xMin: -2, xMax: 2, yMin: -2, yMax: 2, z: 1, columns: 5, rows: 5 };
const rawMap = new Float32Array(25);
rawMap[6] = 10; // (-1, +1)
rawMap[18] = 8; // (+1, -1)
rawMap[4] = 6; // unmatched (+2, +2)
const evaluation = evaluateDasBand({
rawMap,
scan,
sources: [
{ id: "a", position: [-1, 1, 1] },
{ id: "b", position: [1, -1, 1] },
],
truthRadiusM: 0.25,
matchToleranceM: 0.25,
sidelobeExclusionRadiusM: 0.25,
detectionThresholdRatio: 0.5,
minimumPeakDistanceM: 0,
});
assertEqual(evaluation.detections.length, 3, "threshold detections");
assertEqual(evaluation.counts.truePositive, 2, "true positives");
assertEqual(evaluation.counts.falsePositive, 1, "false positives");
assertEqual(evaluation.counts.falseNegative, 0, "false negatives");
assertNear(evaluation.metrics.precision, 2 / 3, 1e-12, "multi-source precision");
assertNear(evaluation.metrics.recall, 1, 1e-12, "multi-source recall");
assertNear(evaluation.metrics.energyInsideTruthRatio, 18 / 24, 1e-12, "union truth energy");
assertNear(evaluation.metrics.peakToSidelobeDb, 10 * Math.log10(10 / 6), 1e-12, "multi-source PSLR");
assert(
evaluation.sourceMatches.every((match) => match.withinTolerance && match.localizationErrorM === 0),
"both sources matched exactly",
);
});
test("zero map 평가가 빈 peak와 유한한 metric을 반환한다", () => {
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 1, columns: 2, rows: 2 };
const evaluation = evaluateDasBand({
rawMap: new Float32Array(4),
scan,
sources: [{ id: "silent", position: [0, 0, 1] }],
});
assertEqual(evaluation.globalPeak, null, "zero-map global peak");
assertEqual(evaluation.localPeaks.length, 0, "zero-map local peaks");
assertEqual(evaluation.detections.length, 0, "zero-map detections");
assertEqual(evaluation.sourceMatches[0].peak, null, "zero-map source remains unmatched");
assertEqual(evaluation.metrics.energyInsideTruthRatio, 0, "zero-map energy ratio");
assertEqual(evaluation.metrics.peakToSidelobeDb, 0, "zero-map PSLR");
assertEqual(evaluation.metrics.precision, 0, "zero-map precision");
assertEqual(evaluation.metrics.recall, 0, "zero-map recall");
assertEqual(evaluation.diagnostics.mainLobeWidthM, 0, "zero-map main-lobe width");
Object.values(evaluation.metrics).forEach((value) => assert(Number.isFinite(value), "finite zero-map metric"));
});
test("flat plateau와 main-lobe width가 deterministic하다", () => {
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 0, columns: 3, rows: 3 };
const rawMap = Float32Array.from({ length: 9 }, () => 1);
const peaks = findLocalPeaks(rawMap, scan);
assertEqual(peaks.length, 1, "flat plateau represented once");
assertEqual(peaks[0].index, 0, "flat plateau uses lowest row-major index");
const first = evaluateDasBand({
rawMap,
scan,
sources: [{ id: "corner", position: [-1, 1, 0] }],
truthRadiusM: 0,
matchToleranceM: 0,
minimumPeakDistanceM: 0,
});
const second = evaluateDasBand({
rawMap,
scan,
sources: [{ id: "corner", position: [-1, 1, 0] }],
truthRadiusM: 0,
matchToleranceM: 0,
minimumPeakDistanceM: 0,
});
assertEqual(JSON.stringify(first), JSON.stringify(second), "repeatable evaluation result");
const lobe = measureMainLobe(rawMap, scan, 0, 0.5);
assertEqual(lobe.pixelCount, 9, "flat half-power component size");
assertEqual(lobe.widthXM, 3, "main-lobe x pixel footprint");
assertEqual(lobe.widthYM, 3, "main-lobe y pixel footprint");
assertNear(lobe.equivalentDiameterM, 6 / Math.sqrt(Math.PI), 1e-12, "equivalent main-lobe diameter");
});
test("result exporter가 schema 허용 metric만 선택하고 내부 필드를 제거한다", () => {
const scan = { xMin: 0, xMax: 1, yMin: 0, yMax: 1, z: 1, columns: 2, rows: 2 };
const evaluation = evaluateDasBand({
rawMap: Float32Array.of(5, 0, 0, 1),
scan,
sources: [{ id: "truth", position: [0, 1, 3] }],
truthRadiusM: 0,
matchToleranceM: 0,
sidelobeExclusionRadiusM: 0,
minimumPeakDistanceM: 0,
});
const exported = exportBandEvaluation(evaluation, { metricNames: ["precision", "recall"] });
assertEqual(Object.keys(exported.metrics).join(","), "precision,recall", "selected metric keys");
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");
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");
assertEqual(exported.evaluation.matchToleranceM, 0, "match tolerance exported");
assertEqual(
Object.keys(selectResultMetrics(evaluation)).join(","),
RESULT_METRIC_KEYS.join(","),
"default schema metric selection",
);
const unsupported = captureError(() => selectResultMetrics(evaluation, ["gridResolutionM"]));
assert(unsupported instanceof RangeError, "unsupported result metric is rejected");
});
test("evaluation이 non-finite, negative, shape 오류를 입력 경계에서 거부한다", () => {
const scan = { xMin: 0, xMax: 1, yMin: 0, yMax: 1, z: 1, columns: 2, rows: 2 };
for (const invalidValue of [Number.NaN, Number.POSITIVE_INFINITY, -1]) {
const rawMap = [0, 0, 0, invalidValue];
assert(
captureError(() => evaluateDasBand({ rawMap, scan, sources: [] })) instanceof RangeError,
`invalid power rejected: ${invalidValue}`,
);
}
assert(
captureError(() => evaluateDasBand({ rawMap: [0, 0, 0], scan, sources: [] })) instanceof RangeError,
"map shape mismatch rejected",
);
assert(
captureError(() => evaluateDasBand({
rawMap: [0, 0, 0, 0],
scan,
sources: [{ id: "bad", position: [0, Number.NaN, 1] }],
})) instanceof RangeError,
"non-finite source position rejected",
);
assert(
captureError(() => evaluateDasBand({
rawMap: { rows: 2, columns: 2, scale: "normalized", values: [0, 0, 0, 0] },
scan,
sources: [],
})) instanceof RangeError,
"non-power map rejected",
);
});
+252
View File
@@ -0,0 +1,252 @@
import {
DAS_PARAMS_BYTE_LENGTH,
packDasParams,
packMicrophoneCalibration,
} from "/packages/webgpu-kernels/das.wgsl.js";
import {
WebGpuLimitError,
WebGpuUnavailableError,
estimateWebGpuBatchPlan,
estimateWebGpuBatchSize,
packStftBatch,
} from "/packages/webgpu-kernels/das.js";
import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js";
function assertNear(actual, expected, tolerance, label) {
assert(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
);
}
test("WebGPU DAS uniform과 microphone calibration을 WGSL layout으로 packing한다", () => {
const params = packDasParams({
pointCount: 81,
microphoneCount: 16,
frameCount: 7,
binCount: 3,
sampleRate: 8_000,
fftSize: 256,
speedOfSound: 343.25,
});
const view = new DataView(params);
assertEqual(params.byteLength, DAS_PARAMS_BYTE_LENGTH, "uniform byte length");
assertEqual(view.getUint32(0, true), 81, "point count offset");
assertEqual(view.getUint32(4, true), 16, "microphone count offset");
assertEqual(view.getUint32(8, true), 7, "frame count offset");
assertEqual(view.getUint32(12, true), 3, "bin count offset");
assertEqual(view.getFloat32(16, true), Math.fround(8_000), "sample rate offset");
assertEqual(view.getUint32(20, true), 256, "FFT size offset");
assertEqual(view.getFloat32(24, true), Math.fround(343.25), "speed of sound offset");
assertEqual(view.getUint32(28, true), 0, "reserved word");
const calibration = packMicrophoneCalibration([
{ sensitivityDb: 0, phaseOffsetDeg: 0 },
{ sensitivityDb: 6.020599913, phaseOffsetDeg: 90 },
{ sensitivityDb: -6.020599913, phaseOffsetDeg: -45 },
]);
assertEqual(calibration.length, 6, "two f32 values per microphone");
assertNear(calibration[0], 1, 1e-7, "unity inverse gain");
assertNear(calibration[1], 0, 1e-7, "zero phase");
assertNear(calibration[2], 0.5, 1e-6, "+6 dB inverse gain");
assertNear(calibration[3], Math.PI / 2, 1e-6, "+90 degree phase");
assertNear(calibration[4], 2, 1e-6, "-6 dB inverse gain");
assertNear(calibration[5], -Math.PI / 4, 1e-6, "-45 degree phase");
});
test("STFT batch를 bin-frame-microphone-complex 순서로 packing한다", () => {
const stft = {
frameCount: 2,
microphoneCount: 2,
spectra: new Map([
[3, {
real: Float32Array.of(1, 2, 3, 4),
imaginary: Float32Array.of(11, 12, 13, 14),
}],
[7, {
real: Float32Array.of(5, 6, 7, 8),
imaginary: Float32Array.of(15, 16, 17, 18),
}],
]),
};
const packed = packStftBatch(stft, [7, 3]);
assert(packed instanceof Float32Array, "packed STFT type");
const expected = [
5, 15, 6, 16, 7, 17, 8, 18,
1, 11, 2, 12, 3, 13, 4, 14,
];
assertEqual(packed.length, expected.length, "packed STFT length");
expected.forEach((value, index) => assertEqual(packed[index], value, `packed STFT value ${index}`));
});
test("WebGPU batch size가 storage, dispatch, memory 한도를 모두 지킨다", () => {
const workload = {
selectedBins: [4, 5, 6, 7, 8],
bandBins: [[4, 5, 6, 7, 8]],
frameCount: 2,
microphoneCount: 4,
pointCount: 8,
distanceBytes: 128,
outputBytes: 64,
spectrumBytesPerBin: 64,
baseWorkingBytes: 256,
};
const limits = {
maxStorageBufferBindingSize: 192,
maxBufferSize: 192,
maxComputeWorkgroupsPerDimension: 2,
maxComputeInvocationsPerWorkgroup: 64,
maxComputeWorkgroupSizeX: 64,
};
const batchSize = estimateWebGpuBatchSize({
workload,
limits,
maximumGpuWorkingBytes: 4_096,
maximumHostWorkingBytes: 4_096,
});
assertEqual(batchSize, 2, "dispatch y dimension is the tightest batch limit");
const plan = estimateWebGpuBatchPlan({
workload,
limits,
maximumGpuWorkingBytes: 4_096,
maximumHostWorkingBytes: 4_096,
});
assertEqual(plan.pointBatchSize, 2, "steering point dispatch batch");
assertEqual(plan.steeringBytes, 128, "two-pass steering buffer bytes");
let limitError;
try {
estimateWebGpuBatchSize({
workload,
limits: { ...limits, maxStorageBufferBindingSize: 31 },
maximumGpuWorkingBytes: 4_096,
maximumHostWorkingBytes: 4_096,
});
} catch (error) {
limitError = error;
}
assert(limitError instanceof WebGpuLimitError, "static calibration buffer limit error type");
let invocationLimitError;
try {
estimateWebGpuBatchSize({
workload,
limits,
maximumGpuWorkingBytes: 4_096,
maximumHostWorkingBytes: 4_096,
maximumMacsPerInvocation: 7,
});
} catch (error) {
invocationLimitError = error;
}
assert(invocationLimitError instanceof WebGpuLimitError, "per-invocation watchdog guard error type");
const scalingWorkload = {
selectedBins: [10, 11],
bandBins: [[10, 11]],
frameCount: 46,
microphoneCount: 192,
pointCount: 40_000,
distanceBytes: 40_000 * 192 * 4,
outputBytes: 40_000 * 4 * 2,
spectrumBytesPerBin: 46 * 192 * 8,
baseWorkingBytes: 40_000 * 192 * 4 + 40_000 * 4 * 3 + 512 * 8 * 2,
};
const scalingPlan = estimateWebGpuBatchPlan({
workload: scalingWorkload,
limits: {
maxStorageBufferBindingSize: 128 * 1024 ** 2,
maxBufferSize: 256 * 1024 ** 2,
maxComputeWorkgroupsPerDimension: 65_535,
maxComputeInvocationsPerWorkgroup: 256,
maxComputeWorkgroupSizeX: 256,
maxStorageBuffersPerShaderStage: 8,
maxUniformBufferBindingSize: 65_536,
},
});
assertEqual(scalingPlan.binBatchSize, 2, "200x200 preserves one CPU STFT bin batch");
assert(scalingPlan.pointBatchSize < 40_000, "200x200 steering uses point chunks");
assert(scalingPlan.steeringBytes <= 64 * 1024 ** 2, "steering target is respected");
});
test("backend auto는 typed WebGPU 운용 오류만 CPU로 fallback한다", async () => {
const calls = [];
const options = { experiment: { beamforming: { backend: "auto" } } };
const cpuResult = { backend: "cpu", bands: [], marker: "fallback-result" };
const result = await computeDasWithBackend(options, {
computeGpu: async () => {
calls.push("gpu");
throw new WebGpuUnavailableError("adapter unavailable");
},
computeCpu: async () => {
calls.push("cpu");
return cpuResult;
},
});
assertEqual(calls.join(","), "gpu,cpu", "auto backend order");
assertEqual(result.backend, "cpu", "fallback backend");
assertEqual(result.marker, "fallback-result", "CPU result is preserved");
const limitFallback = await computeDasWithBackend(options, {
computeGpu: async () => {
throw new WebGpuLimitError("device limit");
},
computeCpu: async () => cpuResult,
});
assertEqual(limitFallback.backend, "cpu", "device limit also uses CPU fallback in auto mode");
let programmingError;
try {
await computeDasWithBackend(options, {
computeGpu: async () => {
throw new TypeError("programming error");
},
computeCpu: async () => {
throw new Error("CPU must not run");
},
});
} catch (error) {
programmingError = error;
}
assert(programmingError instanceof TypeError, "unexpected GPU errors are not hidden by fallback");
const controller = new AbortController();
controller.abort();
let abortError;
try {
await computeDasWithBackend(
{ ...options, signal: controller.signal },
{
computeGpu: async () => {
throw new WebGpuUnavailableError("adapter unavailable after cancellation");
},
computeCpu: async () => {
throw new Error("cancelled auto mode must not start CPU fallback");
},
},
);
} catch (error) {
abortError = error;
}
assertEqual(abortError?.name, "AbortError", "cancellation wins over GPU fallback");
const explicitOptions = { experiment: { beamforming: { backend: "webgpu" } } };
let explicitError;
try {
await computeDasWithBackend(explicitOptions, {
computeGpu: async () => {
throw new WebGpuLimitError("forced device limit");
},
computeCpu: async () => {
throw new Error("explicit webgpu must not run CPU fallback");
},
});
} catch (error) {
explicitError = error;
}
assert(explicitError instanceof WebGpuLimitError, "explicit WebGPU preserves operational error");
});