Compare commits
10 Commits
4c5756a5a9
...
b03eccde50
| Author | SHA1 | Date | |
|---|---|---|---|
| b03eccde50 | |||
| 513de2b51f | |||
| 3317a03d1a | |||
| 2ce96064cf | |||
| d04a4d9f65 | |||
| d5f7530620 | |||
| 08fcf578de | |||
| 6a1197d60e | |||
| 691a4b03ce | |||
| 582d0013e6 |
@@ -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 원클릭 실행 진입점
|
||||
```
|
||||
|
||||
@@ -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>을 여세요.
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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가
|
||||
추정하지 않습니다.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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("초기화 실패");
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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": []
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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(() => {});
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 검출까지 실행합니다.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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`를 참고하세요.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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는 필수 기능이 아니므로 지원되지
|
||||
않는 장치도 동일한 계산 경로를 쓸 수 있습니다.
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./compute.js";
|
||||
export * from "./das.js";
|
||||
export * from "./das.wgsl.js";
|
||||
@@ -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`에서 추가로 검증합니다.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
@@ -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)에 있습니다.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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가 있을 때 수행합니다.
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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")));
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user