Compare commits
7 Commits
fd3d98947a
...
85ad056a73
| Author | SHA1 | Date | |
|---|---|---|---|
| 85ad056a73 | |||
| 713debaf9b | |||
| 256558e601 | |||
| 35c33e360e | |||
| 6e5bd512b9 | |||
| d002a2bd70 | |||
| bc63ada1cd |
@@ -1,6 +1,6 @@
|
||||
# micarrsim
|
||||
|
||||
192채널 MEMS 마이크 배열의 배치와 자유음장 DAS 음향 영상을 비교하는 로컬 브라우저
|
||||
192채널 MEMS 마이크 배열의 배치와 자유음장·저비용 실내 근사에서의 DAS 음향 영상을 비교하는 로컬 브라우저
|
||||
시뮬레이터입니다. Dataset Builder에서 녹음 파일·합성 신호와 이동 경로를 포함한 재현 가능한
|
||||
음향 장면을 만들고 Acoustic Camera에서 CPU 또는 WebGPU로 배열 응답, acoustic map과 위치 검출
|
||||
지표를 계산하거나 이동 장면을 시간축으로 재생합니다.
|
||||
@@ -46,7 +46,7 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
|
||||
1. 시작 화면에서 **Acoustic Camera**를 엽니다.
|
||||
2. 기본 192채널 rectangular 배열, single-tone scene과 50×50 experiment가 준비될 때까지
|
||||
기다립니다.
|
||||
3. **기본 예제 실행**을 누릅니다.
|
||||
3. Snapshot 카드의 **▶ Snapshot 실행**을 누릅니다.
|
||||
4. 계산이 끝나면 overlay와 평가 지표를 확인하고 **Result JSON**을 누릅니다.
|
||||
|
||||
실제 완료 시간은 브라우저와 장치에 따라 다릅니다. CPU fallback에서는 WebGPU보다 오래 걸릴 수
|
||||
@@ -54,8 +54,8 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
|
||||
|
||||
### 이동 음원 바로 보기
|
||||
|
||||
1. Acoustic Camera에서 **이동 예제**를 누릅니다.
|
||||
2. **라이브 준비**를 눌러 2 Hz acoustic map을 계산합니다.
|
||||
1. Acoustic Camera에서 **이동 예제 열기**를 누릅니다.
|
||||
2. Moving 카드의 **Map 준비**를 눌러 2 Hz acoustic map을 계산합니다.
|
||||
3. **재생**을 누르거나 시간 slider를 움직여 음원과 heatmap의 이동을 확인합니다.
|
||||
|
||||
이 기능은 trajectory가 있는 합성 Scene의 준정적 시간창 재생입니다. 실제 192채널 마이크 장치에서
|
||||
@@ -118,6 +118,23 @@ WebGPU 경로도 신호 합성과 STFT 일부는 CPU에서 수행합니다. grid
|
||||
100×100은 4배, 200×200은 16배입니다. 처음에는 50×50을 사용하고 해상도가 필요할 때만 높이세요.
|
||||
실행 시간은 microphone 수, scene 길이, FFT, 대역 폭과 장치에 함께 좌우됩니다.
|
||||
|
||||
## 음향 환경과 마이크 응답
|
||||
|
||||
Camera의 **고급 설정**에서 자유음장, 흡음된 실내, 일반 실내와 반사 강한 실내를 선택할 수
|
||||
있습니다. 실내 preset은 현재 Array·Scene·scan을 감싸는 shoebox room을 만들고 직접음에 다음의
|
||||
희소 경로를 추가합니다.
|
||||
|
||||
- 여섯 면의 1차 image-source 반사와 결정적 scattering
|
||||
- 축 정렬 occluder의 직접음 감쇠와 최단 corner 우회 회절
|
||||
- RT60·gain·tap 수로 제한한 deterministic diffuse tail
|
||||
|
||||
각 마이크의 Array JSON에는 선택적 `frequencyResponse` 측정 curve를 넣을 수 있습니다.
|
||||
합성기는 curve를 수신 채널에 적용하고, 기본 DAS는 알려진 curve를 역보정합니다. **마이크 주파수
|
||||
응답 처리**를 보정하지 않음으로 바꾸면 같은 curve를 합성에만 적용해 robustness를 비교합니다.
|
||||
응답 notch의 양의 역보정 boost는 기본 24 dB로 제한합니다.
|
||||
필드, preset별 경로 예산과 물리적 한계는
|
||||
[음향 환경과 마이크 응답 모델](docs/acoustic-model.md)에 정리되어 있습니다.
|
||||
|
||||
## 위치 추정, 빔 분포와 복수 음원
|
||||
|
||||
DAS 출력은 각 scan point의 상대 acoustic power map이며 위치 확률분포가 아닙니다. Camera는 map의
|
||||
@@ -143,13 +160,22 @@ index와 audio asset 재생 위치가 이어지도록 신호를 합성합니다.
|
||||
- 모델: 준정적 위치 변화이며 Doppler와 창 내부의 연속 전파 경로 변화는 포함하지 않음
|
||||
- 실제 장치: 물리 192채널 capture/stream gateway는 하드웨어 형식이 정해진 뒤 별도 adapter가 필요
|
||||
|
||||
Scene 합성 없이 동기식 `Float32Array` 마이크 채널을 바로 처리하는
|
||||
`createLocalizationPlan`, `localizeMultichannel`과 `StreamingDasLocalizer`가
|
||||
`packages/acoustics-core`에 분리되어 있습니다. 고정 ring buffer, oldest-first drop,
|
||||
반복 geometry·steering cache와 `AbortSignal` 취소를 제공하며 실제 capture 장치에는
|
||||
channel/clock/transport adapter만 별도로 연결합니다. 실시간 여부는 각 결과의
|
||||
`processingTimeMs <= deadlineMs`와 누적 `deadlineMisses`·`droppedSamples`로
|
||||
해당 장치에서 확인합니다. 복사할 파일과 운용 계약은
|
||||
[실측 다채널 위치추정 코어 포팅](docs/realtime-porting.md)을 참고하세요.
|
||||
|
||||
## Result JSON
|
||||
|
||||
Result JSON은 계약 버전 `1.0.0`의 `micarrsim.result` 문서입니다. 주요 내용은 다음과 같습니다.
|
||||
|
||||
- 실행한 `experimentId`, `arrayId`, `sceneId`, 실제 `backend`와 생성 시각
|
||||
- Array·Scene·Experiment의 canonical SHA-256인 `inputHash`
|
||||
- 정규화 전 peak, 적용 scale과 clipping 수를 담은 `signalStats`
|
||||
- 정규화 전 peak, 적용 scale, clipping 수와 음향 모델·희소 경로 수를 담은 `signalStats`
|
||||
- 전체·신호 생성·beamforming 시간인 `timingMs`
|
||||
- 주파수 대역별 선형 power `rawMap`, 표시용 `displayMap`, 검출 `peaks`
|
||||
- 위치 오차, precision/recall, truth 반경 내 energy, peak-to-sidelobe, main-lobe 폭을 계산하기
|
||||
@@ -166,10 +192,12 @@ noise source는 스펙트럼을 미리 알 수 없으므로 모든 band의 groun
|
||||
않습니다. 선택한 파일은 브라우저 메모리에서 읽으며 생성한 JSON/WAV는 브라우저 다운로드로만
|
||||
저장됩니다.
|
||||
|
||||
현재 음향 모델은 직접 경로와 거리 감쇠를 사용하는 자유음장 모델입니다. 반사, 잔향, 회절,
|
||||
차폐, 바람, 실제 MEMS 비선형과 장착 구조는 모델링하지 않습니다. Beamformer는 conventional
|
||||
DAS이며 UI의 DAMAS와 CLEAN-SC는 아직 사용할 수 없습니다. 따라서 결과는 배열·grid·대역의 상대
|
||||
비교와 소프트웨어 검증용이며, 현장 성능 인증이나 안전 판단을 대신하지 않습니다.
|
||||
음향 모델은 직접 경로의 자유음장과 1차 반사·scattering·occluder 회절·RT60 tail을 제한된
|
||||
경로로 계산하는 shoebox 근사를 제공합니다. 파동 방정식, 고차 반사, 재질별 주파수 의존 흡음,
|
||||
복잡한 mesh 회절, 바람, Doppler, 마이크 self-noise·비선형, ADC와 channel clock은 모델링하지
|
||||
않습니다. Beamformer는 conventional DAS이며 UI의 DAMAS와 CLEAN-SC는 아직 사용할 수 없습니다.
|
||||
따라서 결과는 배열·grid·대역의 상대 비교와 소프트웨어 검증용이며, 현장 성능 인증이나 안전
|
||||
판단을 대신하지 않습니다.
|
||||
|
||||
## 배열 예제
|
||||
|
||||
@@ -200,6 +228,8 @@ test.cmd
|
||||
## 저장 형식과 개발 문서
|
||||
|
||||
- [`docs/data-contracts.md`](docs/data-contracts.md): Array·Scene·Experiment·Result 계약
|
||||
- [`docs/acoustic-model.md`](docs/acoustic-model.md): 마이크 응답과 자유음장·shoebox 희소 근사
|
||||
- [`docs/realtime-porting.md`](docs/realtime-porting.md): 실측 다채널 위치추정 API 포팅
|
||||
- [`schemas/README.md`](schemas/README.md): JSON Schema 구성
|
||||
- [`docs/execution-plan.md`](docs/execution-plan.md): 구현 단계와 기술 결정
|
||||
- [`goal.md`](goal.md): 프로젝트 목표
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Acoustic Camera
|
||||
|
||||
Scene과 마이크 배열로 자유음장 microphone signal을 합성하고, STFT와 conventional DAS를 통해
|
||||
주파수 대역별 acoustic map과 ground-truth 평가 지표를 만드는 브라우저 앱입니다.
|
||||
Scene과 마이크 배열로 자유음장 또는 저비용 shoebox microphone signal을 합성하고, STFT와
|
||||
conventional DAS를 통해 주파수 대역별 acoustic map과 ground-truth 평가 지표를 만드는 브라우저
|
||||
앱입니다. 정지 장면은 Snapshot, trajectory 장면은 Moving 흐름으로 분리되어 있습니다.
|
||||
|
||||
## 열기와 기본 예제
|
||||
## 열기와 Snapshot
|
||||
|
||||
저장소의 `start.cmd`를 실행한 뒤 시작 화면에서 **Acoustic Camera**를 선택하거나
|
||||
<http://127.0.0.1:4173/apps/acoustic-camera/>을 엽니다. `index.html`을 직접 열지 마세요.
|
||||
@@ -12,11 +13,11 @@ Scene과 마이크 배열로 자유음장 microphone signal을 합성하고, STF
|
||||
|
||||
1. 기본 fixture가 준비될 때까지 기다립니다.
|
||||
2. 192채널 rectangular 배열, single-tone scene과 50×50 grid를 그대로 둡니다.
|
||||
3. **기본 예제 실행**을 누릅니다.
|
||||
3. 상단 작업 순서에서 입력·설정 상태를 확인한 뒤 Snapshot 카드의 **▶ Snapshot 실행**을 누릅니다.
|
||||
4. 완료 후 overlay, 위치 오차와 timing을 확인하고 **Result JSON**을 저장합니다.
|
||||
|
||||
이동 장면을 바로 보려면 **이동 예제 → 라이브 준비 → 재생** 순서로 누릅니다. 기본 예제는
|
||||
24×18 grid와 2 Hz map rate를 사용해 CPU fallback에서도 준비량을 줄였습니다.
|
||||
이동 장면을 바로 보려면 Moving 카드에서 **이동 예제 열기 → Map 준비 → 재생** 순서로 누릅니다.
|
||||
이동 예제는 24×18 grid와 2 Hz map rate를 사용해 CPU fallback에서도 준비량을 줄였습니다.
|
||||
|
||||
## 사용자 입력 불러오기
|
||||
|
||||
@@ -51,6 +52,45 @@ Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라
|
||||
WebGPU가 감지돼도 실제 장치는 실행 시 확인합니다. 계산 중 **계산 취소**를 누르면 Worker를
|
||||
종료하며, 이미 완료된 이전 결과가 있으면 그대로 남습니다.
|
||||
|
||||
## 음향 환경과 마이크 주파수 응답
|
||||
|
||||
**고급 설정 → 음향 환경**에서 다음 preset을 선택합니다.
|
||||
|
||||
- **자유음장 · 직접음만**: 거리 지연과 `1/r` 감쇠의 기준선
|
||||
- **흡음된 실내 · 짧은 잔향**: 수신점당 최대 17개 sparse path
|
||||
- **일반 실내 · 균형**: 수신점당 최대 19개 sparse path
|
||||
- **반사 강한 실내**: 수신점당 최대 23개 sparse path
|
||||
|
||||
실내 preset은 현재 마이크, 정적·trajectory source와 scan corner를 포함하도록 직육면체 room을
|
||||
자동 추론합니다. 직접음에 여섯 면의 1차 image-source 반사, 결정적 scattering과 tap 수가 제한된
|
||||
RT60 diffuse tail을 추가합니다. Scene JSON에 사용자 `shoebox`와 축 정렬
|
||||
`occluders`를 넣으면 직접음 감쇠와 최단 corner 우회 회절도 계산합니다. 사용자 설정은
|
||||
dropdown에 **Scene 사용자 설정**으로 표시되고 preset 값으로 덮어쓰기 전까지 유지됩니다.
|
||||
|
||||
Array의 각 microphone에는 선택적 `frequencyResponse`를 넣을 수 있습니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "mic-001",
|
||||
"position": [-0.225, 0.225, 0],
|
||||
"frequencyResponse": [
|
||||
{ "frequencyHz": 100, "gainDb": -8, "phaseDeg": -12 },
|
||||
{ "frequencyHz": 1000, "gainDb": -0.7, "phaseDeg": -2 },
|
||||
{ "frequencyHz": 10000, "gainDb": 1.2, "phaseDeg": 8 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
curve는 합성 microphone channel에 항상 적용됩니다. **마이크 주파수 응답 처리**의 기본
|
||||
**측정 curve 적용 + 위치추정에서 역보정**은 CPU와 WebGPU DAS에서 알려진 gain/phase를
|
||||
역보정합니다. **측정 curve 적용 + 보정하지 않음**은 합성 응답은 유지하고 역보정만 꺼서 영향을
|
||||
비교합니다. curve가 없는 마이크는 기존 결과를 유지합니다. 응답 notch를 과도하게 증폭하지
|
||||
않도록 역보정의 양의 gain boost는 기본 24 dB로 제한하며, 사용자 Experiment JSON의
|
||||
`beamforming.maxCompensationDb`에서 0–120 dB로 지정할 수 있습니다.
|
||||
|
||||
모델 수식, preset 값, Scene/Array/Experiment 예와 제외 범위는
|
||||
[음향 환경과 마이크 응답 모델](../../docs/acoustic-model.md)을 참고하세요.
|
||||
|
||||
## Grid, STFT와 대역
|
||||
|
||||
50×50은 2,500 points입니다. 이를 1로 보면 100×100은 4배, 200×200은 16배 points를 계산합니다.
|
||||
@@ -87,11 +127,11 @@ WebGPU가 감지돼도 실제 장치는 실행 시 확인합니다. 계산 중 *
|
||||
|
||||
## 이동 음원 라이브
|
||||
|
||||
Scene source에 두 개 이상의 `trajectory` keyframe이 있으면 **라이브 준비**가 활성화됩니다.
|
||||
Scene source에 두 개 이상의 `trajectory` keyframe이 있으면 **Map 준비**가 활성화됩니다.
|
||||
Builder에서는 **시간에 따라 음원 이동**과 경로 미리보기 slider로 이 데이터를 만들 수 있습니다.
|
||||
|
||||
1. Map rate와 backend를 선택합니다. 처음에는 2 Hz와 Auto를 권장합니다.
|
||||
2. **라이브 준비**를 누릅니다. 현재 입력 snapshot을 최대 48개, map당 최대 2,500 points의 짧은
|
||||
2. **Map 준비**를 누릅니다. 현재 입력 snapshot을 최대 48개, map당 최대 2,500 points의 짧은
|
||||
분석창으로 계산합니다.
|
||||
3. **재생** 또는 시간 slider로 이동합니다. Heatmap은 이웃한 acoustic map 사이를 보간하고
|
||||
source 위치는 keyframe 사이를 연속 보간합니다.
|
||||
@@ -107,7 +147,8 @@ Scene 시간에 이어 붙입니다. 한 창 안에서는 중앙 시각의 위
|
||||
|
||||
- `experimentId`, `arrayId`, `sceneId`, `createdAt`, 실제 `backend`
|
||||
- 실행 입력 전체의 canonical SHA-256인 `inputHash`
|
||||
- `signalStats`: 정규화 전 peak, 적용 scale, clipped sample 수
|
||||
- `signalStats`: 정규화 전 peak, 적용 scale, clipped sample 수, 실제 음향 모델과 sparse
|
||||
path 전체·종류별 수
|
||||
- `timingMs`: 전체, signal generation과 beamforming 시간
|
||||
- band별 `rawMap` 선형 power, `displayMap`, 검출 `peaks`
|
||||
- 각 peak의 비보정 half-power 공분산과 RMS ellipse인 `beamSpread`
|
||||
@@ -120,9 +161,19 @@ z가 scan z와 다르면 평면에 투영한 x/y를 사용하므로 UI 경고와
|
||||
|
||||
## 해석 한계
|
||||
|
||||
신호 모델은 직접 경로와 거리 감쇠를 사용하는 자유음장입니다. 반사, 잔향, 회절, 차폐, 바람,
|
||||
센서 비선형과 장착 구조는 포함하지 않습니다. 배열 JSON의 sensitivity/phase calibration은 적용하지만
|
||||
결과는 상대 배열 비교와 알고리즘 검증용이며 실측 인증을 대신하지 않습니다.
|
||||
신호 모델은 직접 경로의 자유음장과 선택적인 shoebox 희소 근사입니다. shoebox는 1차 반사,
|
||||
deterministic scattering, 축 정렬 occluder의 직접음 감쇠·최단 corner 우회 회절과 제한된 RT60
|
||||
tail을 포함합니다. 파동 방정식, 고차 반사, 재질별 주파수 의존 흡음, 복잡한 mesh, 바람, Doppler,
|
||||
센서 self-noise·비선형, ADC·clock과 장착 구조는 포함하지 않습니다. 배열 JSON의
|
||||
sensitivity/phase/frequency-response calibration은 적용하지만 결과는 상대 배열 비교와 알고리즘
|
||||
검증용이며 실측 인증을 대신하지 않습니다.
|
||||
|
||||
실제 동기식 microphone `Float32Array`를 Scene 합성 없이 위치추정하려면
|
||||
[실시간 위치추정 코어 포팅](../../docs/realtime-porting.md)의
|
||||
`createLocalizationPlan`, `localizeMultichannel` 또는
|
||||
`StreamingDasLocalizer`를 사용하세요. 실시간성은 장치에서
|
||||
`processingTimeMs <= deadlineMs`와 `deadlineMisses`·`droppedSamples`를
|
||||
측정해 판정해야 합니다.
|
||||
|
||||
WebGPU 성능을 별도로 측정하려면 서버 실행 후
|
||||
<http://127.0.0.1:4173/benchmarks/webgpu-scaling.html>을 여세요.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
const PROFILE_DEFINITIONS = Object.freeze({
|
||||
"treated-room": Object.freeze({
|
||||
reflection: Object.freeze({ wallGainDb: -14, scattering: 0.15 }),
|
||||
diffuse: Object.freeze({ rt60Seconds: 0.2, gainDb: -26, tapCount: 4 }),
|
||||
}),
|
||||
"typical-room": Object.freeze({
|
||||
reflection: Object.freeze({ wallGainDb: -8, scattering: 0.25 }),
|
||||
diffuse: Object.freeze({ rt60Seconds: 0.4, gainDb: -20, tapCount: 6 }),
|
||||
}),
|
||||
"reflective-room": Object.freeze({
|
||||
reflection: Object.freeze({ wallGainDb: -4, scattering: 0.4 }),
|
||||
diffuse: Object.freeze({ rt60Seconds: 0.8, gainDb: -14, tapCount: 10 }),
|
||||
}),
|
||||
});
|
||||
|
||||
function finitePosition(position) {
|
||||
return Array.isArray(position) && position.length === 3 && position.every(Number.isFinite);
|
||||
}
|
||||
|
||||
function roundCoordinate(value) {
|
||||
return Number(value.toFixed(6));
|
||||
}
|
||||
|
||||
function geometryPositions(scene, arrayDefinition, experiment) {
|
||||
const positions = [];
|
||||
for (const microphone of arrayDefinition?.microphones ?? []) {
|
||||
if (finitePosition(microphone.position)) positions.push(microphone.position);
|
||||
}
|
||||
for (const source of scene?.sources ?? []) {
|
||||
if (finitePosition(source.position)) positions.push(source.position);
|
||||
for (const keyframe of source.trajectory ?? []) {
|
||||
if (finitePosition(keyframe.position)) positions.push(keyframe.position);
|
||||
}
|
||||
}
|
||||
const scan = experiment?.scan;
|
||||
if (scan && ["xMin", "xMax", "yMin", "yMax", "z"].every((key) => Number.isFinite(scan[key]))) {
|
||||
positions.push(
|
||||
[scan.xMin, scan.yMin, scan.z],
|
||||
[scan.xMin, scan.yMax, scan.z],
|
||||
[scan.xMax, scan.yMin, scan.z],
|
||||
[scan.xMax, scan.yMax, scan.z],
|
||||
);
|
||||
}
|
||||
return positions.length > 0 ? positions : [[0, 0, 0], [1, 1, 1]];
|
||||
}
|
||||
|
||||
export function inferShoeboxRoom(scene, arrayDefinition, experiment) {
|
||||
const positions = geometryPositions(scene, arrayDefinition, experiment);
|
||||
const minimum = [0, 1, 2].map((axis) =>
|
||||
Math.min(...positions.map((position) => position[axis])),
|
||||
);
|
||||
const maximum = [0, 1, 2].map((axis) =>
|
||||
Math.max(...positions.map((position) => position[axis])),
|
||||
);
|
||||
const padding = [0, 1, 2].map((axis) =>
|
||||
Math.max(axis === 2 ? 0.75 : 1, (maximum[axis] - minimum[axis]) * 0.35),
|
||||
);
|
||||
return {
|
||||
min: minimum.map((value, axis) => roundCoordinate(value - padding[axis])),
|
||||
max: maximum.map((value, axis) => roundCoordinate(value + padding[axis])),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyAcousticProfile(scene, arrayDefinition, experiment, profileId) {
|
||||
if (!scene || typeof scene !== "object") throw new TypeError("scene이 필요합니다.");
|
||||
if (profileId === "free-field") {
|
||||
scene.acoustics = { model: "free-field" };
|
||||
return scene.acoustics;
|
||||
}
|
||||
const definition = PROFILE_DEFINITIONS[profileId];
|
||||
if (!definition) throw new RangeError("지원하지 않는 음향 환경 profile: " + profileId);
|
||||
scene.acoustics = {
|
||||
model: "shoebox",
|
||||
room: inferShoeboxRoom(scene, arrayDefinition, experiment),
|
||||
reflections: {
|
||||
order: 1,
|
||||
wallGainDb: definition.reflection.wallGainDb,
|
||||
scattering: definition.reflection.scattering,
|
||||
},
|
||||
diffuseReverb: {
|
||||
enabled: true,
|
||||
rt60Seconds: definition.diffuse.rt60Seconds,
|
||||
gainDb: definition.diffuse.gainDb,
|
||||
tapCount: definition.diffuse.tapCount,
|
||||
},
|
||||
};
|
||||
return scene.acoustics;
|
||||
}
|
||||
|
||||
export function detectAcousticProfile(scene) {
|
||||
const acoustics = scene?.acoustics;
|
||||
if (!acoustics || acoustics.model === "free-field") return "free-field";
|
||||
if (acoustics.model !== "shoebox" || (acoustics.occluders?.length ?? 0) > 0) return "custom";
|
||||
for (const [profileId, definition] of Object.entries(PROFILE_DEFINITIONS)) {
|
||||
if (
|
||||
acoustics.reflections?.order === 1 &&
|
||||
acoustics.reflections.wallGainDb === definition.reflection.wallGainDb &&
|
||||
acoustics.reflections.scattering === definition.reflection.scattering &&
|
||||
acoustics.diffuseReverb?.enabled === true &&
|
||||
acoustics.diffuseReverb.rt60Seconds === definition.diffuse.rt60Seconds &&
|
||||
acoustics.diffuseReverb.gainDb === definition.diffuse.gainDb &&
|
||||
acoustics.diffuseReverb.tapCount === definition.diffuse.tapCount
|
||||
) {
|
||||
return profileId;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
export function maximumSparsePathsPerReceiver(scene) {
|
||||
const acoustics = scene?.acoustics;
|
||||
if (!acoustics || acoustics.model === "free-field") return 1;
|
||||
let count = 1;
|
||||
const reflections = acoustics.reflections;
|
||||
if (reflections?.order === 1) {
|
||||
count += 6;
|
||||
if (reflections.scattering > 0) count += 6;
|
||||
}
|
||||
count += acoustics.occluders?.length ?? 0;
|
||||
if (acoustics.diffuseReverb?.enabled) count += acoustics.diffuseReverb.tapCount;
|
||||
return count;
|
||||
}
|
||||
+183
-116
@@ -13,146 +13,120 @@
|
||||
<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="run-status" id="run-status">기본 입력 준비 중…</span>
|
||||
<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>
|
||||
|
||||
<nav class="workflow-strip" id="camera-workflow" aria-label="Acoustic Camera 작업 순서">
|
||||
<a href="#input-step" data-workflow-step="1"><span>1</span><strong>입력</strong><small>Scene · Array</small></a>
|
||||
<a href="#configure-step" data-workflow-step="2"><span>2</span><strong>설정</strong><small>Grid · Band</small></a>
|
||||
<a href="#run-step" data-workflow-step="3"><span>3</span><strong>실행</strong><small>정지 · 이동</small></a>
|
||||
<a href="#inspect-step" data-workflow-step="4"><span>4</span><strong>확인</strong><small>Map · 지표</small></a>
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
<aside class="control-panel" aria-label="입력 및 시뮬레이션 설정">
|
||||
<section class="workflow-card input-card" id="input-step" data-workflow-section="1">
|
||||
<div class="step-heading">
|
||||
<span class="step-number">1</span>
|
||||
<div><h2>입력 준비</h2><p>기본 예제를 쓰거나 파일을 순서대로 교체하세요.</p></div>
|
||||
</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 class="workspace-tools input-file-grid" aria-label="입력 파일">
|
||||
<label class="file-button file-choice" title="Scene JSON을 선택하세요."><span class="file-sequence">1</span><span><strong>Scene</strong><small>장면·음원</small></span><input id="scene-input" type="file" accept="application/json,.json" /></label>
|
||||
<label class="file-button file-choice" title="사용자 배열이 있을 때 Array JSON을 선택하세요."><span class="file-sequence">2</span><span><strong>Array</strong><small>마이크 배열</small></span><input id="array-input" type="file" accept="application/json,.json" /></label>
|
||||
<label class="file-button file-choice" title="현재 Scene과 Array를 참조하는 Experiment JSON을 선택하세요."><span class="file-sequence">3</span><span><strong>Experiment</strong><small>분석 설정</small></span><input id="experiment-input" type="file" accept="application/json,.json" /></label>
|
||||
<label class="file-button file-choice" title="Scene에 사용한 원본 파일을 다시 선택하세요."><span class="file-sequence">4</span><span><strong>Assets</strong><small>오디오·이미지</small></span><input id="asset-input" type="file" accept="audio/*,image/*,.wav,.flac,.mp3,.ogg,.m4a,.png,.jpg,.jpeg,.webp,.svg" multiple /></label>
|
||||
</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" title="점선은 1× RMS, 실선 영역은 2× RMS 빔 분포입니다. 확률 또는 신뢰구간은 아닙니다."><input id="show-beam-spread" type="checkbox" checked /><span>빔 분포</span></label>
|
||||
<label class="check-field"><input id="show-truth" type="checkbox" checked /><span>Ground truth</span></label>
|
||||
</div>
|
||||
|
||||
<section class="live-panel" aria-labelledby="live-title">
|
||||
<div class="live-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Moving-source timeline</p>
|
||||
<h2 id="live-title">이동 음원 라이브</h2>
|
||||
<p id="live-status" aria-live="polite">trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.</p>
|
||||
</div>
|
||||
<div class="live-actions">
|
||||
<button class="button button--ghost" id="load-moving-example-button" type="button">이동 예제</button>
|
||||
<button class="button button--primary" id="prepare-live-button" type="button" disabled>라이브 준비</button>
|
||||
<button class="button button--ghost" id="live-play-button" type="button" disabled>▶ 재생</button>
|
||||
</div>
|
||||
<div class="input-summary">
|
||||
<div class="compact-section-heading"><h3>현재 장면</h3><a class="builder-link" href="/apps/dataset-builder/">Builder에서 편집 →</a></div>
|
||||
<div class="source-summary" id="source-summary"></div>
|
||||
<div class="asset-list" id="asset-list"></div>
|
||||
</div>
|
||||
<div class="live-controls">
|
||||
<label class="live-scrubber"><span id="live-time-label">0.00 / 0.00 s</span><input id="live-time" type="range" min="0" max="0" step="0.001" value="0" disabled /></label>
|
||||
<label class="compact-select"><span>Map rate</span><select id="live-map-rate"><option value="2" selected>2 Hz</option><option value="5">5 Hz</option><option value="8">8 Hz</option></select></label>
|
||||
<label class="compact-select"><span>속도</span><select id="live-speed"><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option></select></label>
|
||||
|
||||
<div class="array-card">
|
||||
<div class="array-heading"><div><span class="compact-label">MIC ARRAY</span><h3 id="array-title">배열</h3></div><strong id="microphone-count">— ch</strong></div>
|
||||
<label class="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>
|
||||
<details class="inline-details" id="array-details">
|
||||
<summary>배열 미리보기와 좌표 정보</summary>
|
||||
<canvas id="array-canvas" aria-label="마이크 배열 평면도"></canvas>
|
||||
<p id="array-meta">배열을 불러오고 있습니다.</p>
|
||||
</details>
|
||||
</div>
|
||||
<p class="live-note">짧은 분석창의 중앙 위치를 사용하는 준정적 미리보기입니다. Heatmap은 계산된 frame 사이를 보간하고 위치 표시는 연속 이동합니다. 시뮬레이션 장면 재생이며 실제 마이크 스트림 입력은 아닙니다.</p>
|
||||
</section>
|
||||
|
||||
<section class="result-section" aria-live="polite">
|
||||
<div class="section-heading"><div><p class="eyebrow">Evaluation</p><h2>실행 결과</h2></div><span class="result-chip" id="result-badge">아직 실행하지 않음</span></div>
|
||||
<div class="empty-result" id="empty-result">시뮬레이션을 실행하면 위치 오차, energy concentration, sidelobe와 timing을 표시합니다.</div>
|
||||
<div class="metric-grid is-hidden" id="metric-grid"></div>
|
||||
</section>
|
||||
<section class="workflow-card configure-card" id="configure-step" data-workflow-section="2">
|
||||
<div class="step-heading">
|
||||
<span class="step-number">2</span>
|
||||
<div><h2>분석 설정</h2><p>자주 쓰는 항목만 먼저 조정합니다.</p></div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="quick-field-grid">
|
||||
<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>
|
||||
<label class="field"><span>Grid 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>
|
||||
<label class="field"><span>Scene 거리 z (m)</span><input id="scene-distance" type="number" min="0.01" step="0.1" /></label>
|
||||
<label class="field" title="최강 peak 대비 검출 임계값입니다. 낮추면 약한 음원을 더 찾지만 sidelobe 오검출도 늘 수 있습니다."><span>Peak 임계값 (%)</span><input id="detection-threshold" type="number" min="1" max="100" step="1" /></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" title="최강 peak 대비 검출 임계값입니다. 낮추면 약한 음원을 더 찾지만 sidelobe 오검출도 늘 수 있습니다."><span>Peak 검출 임계값 (%)</span><input id="detection-threshold" type="number" min="1" max="100" step="1" /></label>
|
||||
<label class="field field--wide"><span>후처리</span><select id="postprocess"><option value="none">없음 · raw DAS</option><option disabled>DAMAS · 후속 milestone</option><option disabled>CLEAN-SC · 후속 milestone</option></select></label>
|
||||
<div class="band-config">
|
||||
<div class="compact-section-heading"><h3>주파수 대역</h3><button class="mini-button" id="add-band-button" type="button">+ 대역</button></div>
|
||||
<div class="band-editor-list" id="band-list"></div>
|
||||
</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>
|
||||
<details class="advanced-settings" id="advanced-settings">
|
||||
<summary><span>고급 설정</span><small>음향 · 범위 · STFT · 식별자</small></summary>
|
||||
<div class="advanced-settings__body">
|
||||
<div class="advanced-group">
|
||||
<h3>실험과 환경</h3>
|
||||
<div class="advanced-field-grid">
|
||||
<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>
|
||||
<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 field--wide"><span>음향 환경</span><select id="acoustic-profile"><option value="free-field">자유음장 · 직접음만</option><option value="treated-room">흡음된 실내 · 짧은 잔향</option><option value="typical-room">일반 실내 · 균형</option><option value="reflective-room">반사 강한 실내</option><option value="custom" disabled>Scene 사용자 설정</option></select></label>
|
||||
<label class="field field--wide"><span>마이크 주파수 응답 처리</span><select id="frequency-response-mode"><option value="compensate">측정 curve 적용 + 위치추정에서 역보정</option><option value="raw">측정 curve 적용 + 보정하지 않음</option></select></label>
|
||||
</div>
|
||||
<p class="acoustic-environment-summary" id="acoustic-environment-summary">음향 모델을 확인하고 있습니다.</p>
|
||||
</div>
|
||||
<div class="advanced-group">
|
||||
<h3>Scan 범위</h3>
|
||||
<div class="advanced-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>
|
||||
</div>
|
||||
<div class="advanced-group">
|
||||
<h3>STFT와 표시</h3>
|
||||
<div class="advanced-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>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<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>
|
||||
<details class="output-card utility-details" id="document-tools">
|
||||
<summary>Scene · Experiment 문서 저장</summary>
|
||||
<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>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="validation-card" aria-live="polite">
|
||||
@@ -160,6 +134,99 @@
|
||||
<div><strong id="validation-title">검증 대기</strong><p id="validation-message">입력을 준비하고 있습니다.</p></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="scene-workspace" aria-label="Acoustic image workspace">
|
||||
<div class="workspace-heading">
|
||||
<div><span class="compact-label">CURRENT SCENE</span><h1 id="workspace-title">음향 장면</h1></div>
|
||||
<p class="workspace-meta" id="scene-meta">장면을 불러오고 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<section class="run-stage" id="run-step" data-workflow-section="3" aria-labelledby="run-step-title">
|
||||
<div class="step-heading step-heading--horizontal">
|
||||
<span class="step-number">3</span>
|
||||
<div><h2 id="run-step-title">실행 방식 선택</h2><p>한 장을 계산할지, 이동 경로를 재생할지 선택하세요.</p></div>
|
||||
</div>
|
||||
|
||||
<div class="mode-grid">
|
||||
<article class="mode-card mode-card--snapshot" id="snapshot-mode">
|
||||
<div class="mode-heading">
|
||||
<div><span class="mode-kicker">SNAPSHOT</span><h3>정지 장면</h3></div>
|
||||
<span class="mode-format">1 map</span>
|
||||
</div>
|
||||
<p>현재 위치와 설정으로 전체 장면을 한 번 분석합니다.</p>
|
||||
<div class="mode-actions">
|
||||
<button class="button button--primary button--full" id="run-button" type="button" disabled>▶ Snapshot 실행</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="mode-card mode-card--moving live-panel" id="moving-mode" aria-labelledby="live-title">
|
||||
<div class="mode-heading">
|
||||
<div><span class="mode-kicker">MOVING TIMELINE</span><h3 id="live-title">이동 장면</h3></div>
|
||||
<span class="mode-format">여러 map</span>
|
||||
</div>
|
||||
<p id="live-status" aria-live="polite">trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.</p>
|
||||
<div class="live-actions">
|
||||
<button class="button button--ghost" id="load-moving-example-button" type="button">이동 예제 열기</button>
|
||||
<button class="button button--primary" id="prepare-live-button" type="button" disabled>Map 준비</button>
|
||||
<button class="button button--ghost" id="live-play-button" type="button" disabled>▶ 재생</button>
|
||||
</div>
|
||||
<div class="live-controls">
|
||||
<label class="live-scrubber"><span id="live-time-label">0.00 / 0.00 s</span><input id="live-time" type="range" min="0" max="0" step="0.001" value="0" disabled /></label>
|
||||
<label class="compact-select"><span>Map rate</span><select id="live-map-rate"><option value="2" selected>2 Hz</option><option value="5">5 Hz</option><option value="8">8 Hz</option></select></label>
|
||||
<label class="compact-select"><span>속도</span><select id="live-speed"><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option></select></label>
|
||||
</div>
|
||||
<details class="mode-help"><summary>동작 방식과 한계</summary><p class="live-note">짧은 분석창 중앙의 위치를 쓰는 준정적 시뮬레이션입니다. Heatmap과 위치를 frame 사이에서 보간하며 실제 마이크 스트림 입력은 아닙니다.</p></details>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="inspect-stage" id="inspect-step" data-workflow-section="4" aria-labelledby="inspect-title">
|
||||
<div class="inspect-heading">
|
||||
<div class="step-heading step-heading--horizontal">
|
||||
<span class="step-number">4</span>
|
||||
<div><h2 id="inspect-title">Map과 결과 확인</h2><p>Canvas가 실제 음향 에너지와 추정 위치를 표시합니다.</p></div>
|
||||
</div>
|
||||
<span class="result-chip" id="result-badge">아직 실행하지 않음</span>
|
||||
</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>실행할 준비가 되면 위에서 모드를 선택하세요.</strong><span>결과 heatmap은 이 canvas에 표시됩니다.</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" title="점선은 1× RMS, 실선 영역은 2× RMS 빔 분포입니다. 확률 또는 신뢰구간은 아닙니다."><input id="show-beam-spread" type="checkbox" checked /><span>빔 분포</span></label>
|
||||
<label class="check-field"><input id="show-truth" type="checkbox" checked /><span>Ground truth</span></label>
|
||||
</div>
|
||||
|
||||
<section class="result-section" aria-live="polite">
|
||||
<div class="compact-section-heading"><h3>측정 요약</h3><span>peak · 오차 · beam</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>
|
||||
|
||||
<details class="history-section utility-details" id="comparison-history">
|
||||
<summary><span>배열 비교 기록</span><small>같은 설정의 재실행 결과</small></summary>
|
||||
<div class="history-body">
|
||||
<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>
|
||||
<p class="section-copy">배열을 바꿔 다시 실행하면 같은 scene·scan·STFT 결과를 이 세션에서 비교합니다.</p>
|
||||
<div class="history-list" id="history-list"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="help-panel utility-details" id="camera-help">
|
||||
<summary><span>표시 해석과 한계</span><small>DAS · beam spread · live</small></summary>
|
||||
<div class="help-grid">
|
||||
<p><strong>Heatmap</strong>DAS 공간 power이며 위치 확률분포가 아닙니다.</p>
|
||||
<p><strong>빔 분포</strong>Half-power covariance 폭이며 신뢰구간이 아닙니다.</p>
|
||||
<p><strong>이동 재생</strong>준정적 frame 시뮬레이션이며 실제 장치 스트림이 아닙니다.</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script type="module" src="/packages/ui/runtime-status.js"></script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { evaluateDasBand, exportBandEvaluation } from "/packages/acoustics-core/evaluation.js";
|
||||
import { DEFAULT_MAX_COMPENSATION_DB } from "/packages/acoustics-core/frequency-response.js";
|
||||
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
|
||||
import {
|
||||
validateArrayDefinition,
|
||||
@@ -7,6 +8,11 @@ import {
|
||||
validateScene,
|
||||
} from "/packages/contracts/validation.js";
|
||||
import { createCameraAssetRuntime } from "./asset-runtime.js";
|
||||
import {
|
||||
applyAcousticProfile,
|
||||
detectAcousticProfile,
|
||||
maximumSparsePathsPerReceiver,
|
||||
} from "./acoustic-profiles.js";
|
||||
import {
|
||||
createAnalysisFramePlan,
|
||||
playbackFrameAtTime,
|
||||
@@ -90,6 +96,9 @@ const elements = {
|
||||
sceneDistance: byId("scene-distance"),
|
||||
speedOfSound: byId("speed-of-sound"),
|
||||
sceneSeed: byId("scene-seed"),
|
||||
acousticProfile: byId("acoustic-profile"),
|
||||
acousticEnvironmentSummary: byId("acoustic-environment-summary"),
|
||||
frequencyResponseMode: byId("frequency-response-mode"),
|
||||
backend: byId("backend"),
|
||||
gridPreset: byId("grid-preset"),
|
||||
scanColumns: byId("scan-columns"),
|
||||
@@ -358,6 +367,9 @@ function syncControls() {
|
||||
elements.sceneDistance.value = experiment.scan.z;
|
||||
elements.speedOfSound.value = scene.speedOfSound;
|
||||
elements.sceneSeed.value = scene.seed;
|
||||
elements.acousticProfile.value = detectAcousticProfile(scene);
|
||||
elements.frequencyResponseMode.value =
|
||||
experiment.beamforming.compensateFrequencyResponse === false ? "raw" : "compensate";
|
||||
elements.backend.value = experiment.beamforming.backend;
|
||||
elements.scanColumns.value = experiment.scan.columns;
|
||||
elements.scanRows.value = experiment.scan.rows;
|
||||
@@ -381,6 +393,32 @@ function syncControls() {
|
||||
: "custom";
|
||||
}
|
||||
|
||||
function renderAcousticSummary() {
|
||||
if (!state.scene || !state.arrayDefinition || !state.experiment) return;
|
||||
const profile = detectAcousticProfile(state.scene);
|
||||
const profileLabels = {
|
||||
"free-field": "자유음장",
|
||||
"treated-room": "흡음된 실내",
|
||||
"typical-room": "일반 실내",
|
||||
"reflective-room": "반사 강한 실내",
|
||||
custom: "Scene 사용자 환경",
|
||||
};
|
||||
const responseCurveCount = state.arrayDefinition.microphones.filter(
|
||||
(microphone) => microphone.frequencyResponse?.length >= 2,
|
||||
).length;
|
||||
const maxCompensationDb =
|
||||
state.experiment.beamforming.maxCompensationDb ?? DEFAULT_MAX_COMPENSATION_DB;
|
||||
const responseMode =
|
||||
state.experiment.beamforming.compensateFrequencyResponse === false
|
||||
? "응답 역보정 꺼짐"
|
||||
: "알려진 응답 역보정 ≤ " + formatNumber(maxCompensationDb, 0) + " dB";
|
||||
elements.acousticEnvironmentSummary.textContent =
|
||||
profileLabels[profile] + " · 수신점당 최대 " +
|
||||
maximumSparsePathsPerReceiver(state.scene) + "개 sparse path · 응답 curve " +
|
||||
responseCurveCount + "/" + state.arrayDefinition.microphones.length + " · " + responseMode;
|
||||
document.documentElement.dataset.acousticProfile = profile;
|
||||
}
|
||||
|
||||
function describeSignal(signal) {
|
||||
if (signal.type === "tone") return `${formatNumber(signal.frequency, 0)} Hz tone`;
|
||||
if (signal.type === "noise") return `${signal.color} noise`;
|
||||
@@ -390,6 +428,7 @@ function describeSignal(signal) {
|
||||
function renderDocumentSummary() {
|
||||
const { arrayDefinition, scene, experiment } = state;
|
||||
if (!arrayDefinition || !scene || !experiment) return;
|
||||
renderAcousticSummary();
|
||||
elements.workspaceTitle.textContent = scene.name;
|
||||
elements.sceneMeta.textContent =
|
||||
`${scene.sampleRate.toLocaleString("ko-KR")} Hz · ${formatNumber(scene.duration, 3)} s · ` +
|
||||
@@ -502,12 +541,19 @@ function renderWorkload() {
|
||||
if (!state.arrayDefinition || !state.scene || !state.experiment) return;
|
||||
try {
|
||||
const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment);
|
||||
const maximumPaths = maximumSparsePathsPerReceiver(state.scene);
|
||||
const signalPathBudget =
|
||||
workload.microphoneCount * state.scene.sources.length * maximumPaths;
|
||||
elements.workloadSummary.textContent =
|
||||
`${workload.pointCount.toLocaleString("ko-KR")} points × ${workload.microphoneCount} ch · ` +
|
||||
`${workload.frameCount} frames · ${workload.selectedBinCount} bins · ` +
|
||||
`약 ${formatCount(workload.complexMacCount)} complex MAC · steering ${formatNumber(workload.distanceMiB, 1)} MiB`;
|
||||
`약 ${formatCount(workload.complexMacCount)} complex MAC · ` +
|
||||
`signal ≤ ${formatCount(signalPathBudget)} paths · steering ${formatNumber(workload.distanceMiB, 1)} MiB`;
|
||||
const heavyCpu = state.experiment.beamforming.backend === "cpu" && workload.complexMacCount > 5e8;
|
||||
elements.workloadSummary.classList.toggle("is-warning", heavyCpu || workload.pointCount >= 40000);
|
||||
elements.workloadSummary.classList.toggle(
|
||||
"is-warning",
|
||||
heavyCpu || workload.pointCount >= 40000 || signalPathBudget > 100000,
|
||||
);
|
||||
} catch (error) {
|
||||
elements.workloadSummary.textContent = `연산량을 계산할 수 없습니다 · ${error.message}`;
|
||||
elements.workloadSummary.classList.add("is-warning");
|
||||
@@ -646,6 +692,15 @@ function renderResult() {
|
||||
`${formatNumber(displayed.run.timingMs.signalGeneration, 0)} ms`,
|
||||
`${displayed.snapshot.arrayDefinition.microphones.length} channels`,
|
||||
);
|
||||
if (displayed.run.signalStats.acousticModel) {
|
||||
const modelLabel =
|
||||
displayed.run.signalStats.acousticModel === "shoebox" ? "SPARSE ROOM" : "FREE FIELD";
|
||||
appendMetric(
|
||||
"음향 모델",
|
||||
modelLabel,
|
||||
`${(displayed.run.signalStats.acousticPathCount ?? 0).toLocaleString("ko-KR")} paths 합성`,
|
||||
);
|
||||
}
|
||||
appendMetric(
|
||||
"Beamforming",
|
||||
`${formatNumber(displayed.run.timingMs.beamforming, 0)} ms`,
|
||||
@@ -842,7 +897,7 @@ function applyValidation(validation) {
|
||||
if (!state.active) {
|
||||
elements.runButton.disabled = !validation.valid;
|
||||
if (validation.valid) {
|
||||
elements.runButton.textContent = state.lastRun ? "▶ 다시 실행" : "▶ 기본 예제 실행";
|
||||
elements.runButton.textContent = state.lastRun ? "▶ 다시 실행" : "▶ Snapshot 실행";
|
||||
if (!state.lastRun && document.documentElement.dataset.runState === "idle") {
|
||||
setRunStatus(
|
||||
`${state.arrayDefinition.microphones.length} ch · ` +
|
||||
@@ -1091,7 +1146,7 @@ async function runSimulation() {
|
||||
activity.worker?.terminate();
|
||||
if (state.active === activity) state.active = null;
|
||||
setConfigurationLocked(false);
|
||||
elements.runButton.textContent = state.lastRun ? "▶ 다시 실행" : "▶ 기본 예제 실행";
|
||||
elements.runButton.textContent = state.lastRun ? "▶ 다시 실행" : "▶ Snapshot 실행";
|
||||
renderBandEditors();
|
||||
await validateCurrent();
|
||||
}
|
||||
@@ -1475,6 +1530,21 @@ function installDocumentInputListeners() {
|
||||
state.scene.seed = value;
|
||||
markChanged({ dirty: "scene" });
|
||||
});
|
||||
elements.acousticProfile.addEventListener("change", () => {
|
||||
if (elements.acousticProfile.value === "custom") return syncControls();
|
||||
applyAcousticProfile(
|
||||
state.scene,
|
||||
state.arrayDefinition,
|
||||
state.experiment,
|
||||
elements.acousticProfile.value,
|
||||
);
|
||||
markChanged({ dirty: "scene", syncControls: true });
|
||||
});
|
||||
elements.frequencyResponseMode.addEventListener("change", () => {
|
||||
state.experiment.beamforming.compensateFrequencyResponse =
|
||||
elements.frequencyResponseMode.value === "compensate";
|
||||
markChanged();
|
||||
});
|
||||
elements.backend.addEventListener("change", () => {
|
||||
state.experiment.beamforming.backend = elements.backend.value;
|
||||
markChanged();
|
||||
|
||||
@@ -67,6 +67,14 @@ export function shiftSceneDistance(scene, experiment, nextDistance) {
|
||||
for (const keyframe of source.trajectory ?? []) keyframe.position[2] += delta;
|
||||
}
|
||||
if (scene.image?.plane) scene.image.plane.z += delta;
|
||||
if (scene.acoustics?.room) {
|
||||
scene.acoustics.room.min[2] += delta;
|
||||
scene.acoustics.room.max[2] += delta;
|
||||
}
|
||||
for (const occluder of scene.acoustics?.occluders ?? []) {
|
||||
occluder.min[2] += delta;
|
||||
occluder.max[2] += delta;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceGeometryPositions(scene) {
|
||||
@@ -225,6 +233,15 @@ export function createResultDocument(options) {
|
||||
preNormalizationPeak: run.signalStats.preNormalizationPeak,
|
||||
appliedScale: run.signalStats.appliedScale,
|
||||
clippedSampleCount: run.signalStats.clippedSampleCount,
|
||||
...(run.signalStats.acousticModel
|
||||
? { acousticModel: run.signalStats.acousticModel }
|
||||
: {}),
|
||||
...(Number.isInteger(run.signalStats.acousticPathCount)
|
||||
? { acousticPathCount: run.signalStats.acousticPathCount }
|
||||
: {}),
|
||||
...(run.signalStats.acousticPathCounts
|
||||
? { acousticPathCounts: { ...run.signalStats.acousticPathCounts } }
|
||||
: {}),
|
||||
},
|
||||
timingMs: {
|
||||
total: run.timingMs.total,
|
||||
|
||||
@@ -24,6 +24,9 @@ function signalStats(synthesis) {
|
||||
appliedScale: synthesis.appliedScale,
|
||||
clippedSampleCount: synthesis.clippedSampleCount,
|
||||
peak: synthesis.peak,
|
||||
acousticModel: synthesis.acousticModel,
|
||||
acousticPathCount: synthesis.acousticPathCount,
|
||||
acousticPathCounts: synthesis.acousticPathCounts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,7 +170,7 @@ self.addEventListener("message", async (event) => {
|
||||
await runTimeline(event.data);
|
||||
return;
|
||||
}
|
||||
post("progress", jobId, { stage: "signal", message: "192채널 자유음장 신호를 합성하고 있습니다…" });
|
||||
post("progress", jobId, { stage: "signal", message: "마이크 수신 신호를 합성하고 있습니다…" });
|
||||
const signalStartedAt = performance.now();
|
||||
const synthesis = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
|
||||
+418
-133
@@ -1,148 +1,433 @@
|
||||
.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); }
|
||||
|
||||
.camera-header {
|
||||
position: sticky;
|
||||
z-index: 50;
|
||||
top: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
min-height: 54px;
|
||||
padding: 8px clamp(14px, 2vw, 26px);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.header-status,
|
||||
.header-actions,
|
||||
.live-actions,
|
||||
.overlay-toolbar,
|
||||
.history-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-status { justify-content: flex-end; }
|
||||
.header-actions { justify-content: flex-end; }
|
||||
.run-status {
|
||||
overflow: hidden;
|
||||
max-width: min(42vw, 520px);
|
||||
color: var(--muted);
|
||||
font-size: .7rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.camera-header .status-chip { min-height: 28px; padding: 4px 9px; font-size: .68rem; }
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
color: var(--text);
|
||||
background: var(--surface-raised);
|
||||
cursor: pointer;
|
||||
font-size: .74rem;
|
||||
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,
|
||||
.file-choice:hover,
|
||||
.file-choice:focus-within { border-color: var(--mint); outline: none; }
|
||||
.button:hover:not(:disabled), .mini-button:hover:not(:disabled) { transform: translateY(-1px); }
|
||||
.button:disabled { cursor: not-allowed; opacity: .45; transform: none; }
|
||||
.button--primary { border-color: rgb(101 242 191 / 52%); 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); }
|
||||
.live-panel { margin-top: 12px; padding: 15px 16px; border: 1px solid rgb(88 199 255 / 24%); border-radius: 16px; background: linear-gradient(135deg, rgb(88 199 255 / 7%), rgb(101 242 191 / 3%)); }
|
||||
.live-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||
.live-heading h2 { margin: 0; font-size: .98rem; }
|
||||
.live-heading p:not(.eyebrow) { margin: 5px 0 0; color: var(--muted); font-size: .7rem; }
|
||||
.live-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.live-controls { display: grid; grid-template-columns: minmax(220px, 1fr) auto auto; gap: 12px; align-items: end; margin-top: 14px; }
|
||||
.live-scrubber, .compact-select { display: grid; gap: 6px; color: var(--muted); font-size: .66rem; font-weight: 650; }
|
||||
.live-scrubber input { width: 100%; accent-color: var(--cyan); }
|
||||
.compact-select select { min-height: 34px; padding: 7px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; }
|
||||
.live-note { margin: 10px 0 0; color: var(--muted); font-size: .64rem; line-height: 1.55; }
|
||||
.live-panel.is-ready { border-color: rgb(101 242 191 / 42%); }
|
||||
.live-panel.is-playing { box-shadow: inset 3px 0 0 var(--cyan); }
|
||||
.result-section, .history-section { margin-top: 28px; padding: 20px; border: 1px solid var(--line); border-radius: 18px; background: rgb(255 255 255 / 2%); }
|
||||
.section-heading h2 { margin: 0; font-size: 1.05rem; }
|
||||
.result-chip { padding: 6px 9px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: .68rem; }
|
||||
.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; }
|
||||
|
||||
.workflow-strip {
|
||||
position: sticky;
|
||||
z-index: 40;
|
||||
top: 54px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
padding: 0 clamp(14px, 2vw, 26px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgb(8 17 15 / 94%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.workflow-strip a {
|
||||
display: grid;
|
||||
grid-template-columns: 24px auto;
|
||||
grid-template-rows: auto auto;
|
||||
min-width: 0;
|
||||
padding: 9px 12px;
|
||||
border-right: 1px solid rgb(145 170 162 / 10%);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.workflow-strip a:first-child { border-left: 1px solid rgb(145 170 162 / 10%); }
|
||||
.workflow-strip a:hover, .workflow-strip a:focus-visible { background: rgb(101 242 191 / 5%); outline: none; }
|
||||
.workflow-strip a > span {
|
||||
grid-row: 1 / 3;
|
||||
align-self: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid rgb(101 242 191 / 35%);
|
||||
border-radius: 50%;
|
||||
color: var(--mint);
|
||||
font: 750 .62rem/18px ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
text-align: center;
|
||||
}
|
||||
.workflow-strip strong { overflow: hidden; font-size: .72rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workflow-strip small { overflow: hidden; color: var(--muted); font-size: .58rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.camera-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 360px) minmax(620px, 1fr);
|
||||
width: min(1880px, 100%);
|
||||
min-height: calc(100vh - 103px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
.control-panel {
|
||||
position: sticky;
|
||||
top: 103px;
|
||||
align-self: start;
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 103px);
|
||||
padding: 12px;
|
||||
border-right: 1px solid var(--line);
|
||||
background: rgb(12 24 21 / 88%);
|
||||
backdrop-filter: blur(18px);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.control-panel .workflow-card {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: rgb(255 255 255 / 2%);
|
||||
}
|
||||
.control-panel .workflow-card + .workflow-card,
|
||||
.control-panel .workflow-card + .validation-card { margin-top: 10px; }
|
||||
|
||||
.step-heading { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.step-heading--horizontal { align-items: center; }
|
||||
.step-number {
|
||||
display: inline-grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 7px;
|
||||
color: #06130f;
|
||||
background: var(--mint);
|
||||
font: 800 .67rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
.step-heading h2 { margin: 0; font-size: .9rem; letter-spacing: -.02em; }
|
||||
.step-heading p { margin: 3px 0 0; color: var(--muted); font-size: .65rem; line-height: 1.45; }
|
||||
|
||||
.workspace-tools { display: grid; }
|
||||
.input-file-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; margin-top: 12px; }
|
||||
.file-choice {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 19px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #0b1714;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-choice input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); }
|
||||
.file-sequence { color: var(--mint); font: 700 .58rem ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.file-choice strong, .file-choice small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-choice strong { font-size: .68rem; }
|
||||
.file-choice small { margin-top: 2px; color: var(--muted); font-size: .56rem; }
|
||||
|
||||
.input-summary, .array-card, .band-config { margin-top: 12px; padding-top: 11px; border-top: 1px solid rgb(145 170 162 / 13%); }
|
||||
.compact-section-heading, .array-heading, .inspect-heading, .mode-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.compact-section-heading h3, .array-heading h3 { margin: 0; font-size: .75rem; }
|
||||
.compact-section-heading > span { color: var(--muted); font-size: .61rem; }
|
||||
.compact-label, .mode-kicker { color: var(--mint); font: 750 .56rem ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .1em; }
|
||||
.builder-link { color: var(--cyan); font-size: .6rem; text-decoration: none; }
|
||||
.source-summary, .asset-list { display: grid; gap: 6px; margin-top: 8px; }
|
||||
.source-line { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: .63rem; }
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
font-size: .61rem;
|
||||
}
|
||||
.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; }
|
||||
.asset-empty { color: var(--muted); font-size: .62rem; }
|
||||
.array-heading > strong { color: var(--mint); font: 750 .68rem ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
|
||||
.field { display: grid; min-width: 0; gap: 5px; }
|
||||
.field > span { color: var(--muted); font-size: .61rem; font-weight: 650; }
|
||||
.field input, .field select, .band-editor input, .compact-select select {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
background: #0b1714;
|
||||
font-size: .68rem;
|
||||
}
|
||||
@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; }
|
||||
.live-heading { align-items: stretch; flex-direction: column; }
|
||||
.live-actions { justify-content: flex-start; }
|
||||
.field input:focus, .field select:focus, .band-editor input:focus, .compact-select select:focus {
|
||||
border-color: var(--mint);
|
||||
outline: 2px solid rgb(101 242 191 / 10%);
|
||||
}
|
||||
.array-card > .field { margin-top: 9px; }
|
||||
.quick-field-grid, .advanced-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.quick-field-grid { margin-top: 12px; }
|
||||
.field--wide { grid-column: 1 / -1; }
|
||||
.workload-summary {
|
||||
margin: 9px 0 0;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
background: #0b1714;
|
||||
font: 600 .6rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
.workload-summary.is-warning { color: var(--amber); }
|
||||
.acoustic-environment-summary {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: .6rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mini-button, .text-button {
|
||||
padding: 5px 7px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: .62rem;
|
||||
}
|
||||
.mini-button { border-color: var(--line); }
|
||||
.band-editor-list { display: grid; gap: 7px; margin-top: 8px; }
|
||||
.band-editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(66px, 1fr) minmax(55px, .72fr) minmax(55px, .72fr) 30px;
|
||||
gap: 5px;
|
||||
padding: 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: #0b1714;
|
||||
}
|
||||
.band-editor input { min-width: 0; }
|
||||
.band-editor input[type="color"] { padding: 3px; }
|
||||
.band-editor__remove { border: 0; border-radius: 7px; color: var(--danger); background: rgb(255 123 115 / 8%); cursor: pointer; }
|
||||
|
||||
details > summary { list-style: none; }
|
||||
details > summary::-webkit-details-marker { display: none; }
|
||||
.inline-details, .advanced-settings, .utility-details, .mode-help {
|
||||
margin-top: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: rgb(255 255 255 / 1.5%);
|
||||
}
|
||||
.inline-details > summary, .advanced-settings > summary, .utility-details > summary, .mode-help > summary {
|
||||
position: relative;
|
||||
padding: 9px 30px 9px 10px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: .64rem;
|
||||
font-weight: 680;
|
||||
}
|
||||
.inline-details > summary::after, .advanced-settings > summary::after, .utility-details > summary::after, .mode-help > summary::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
color: var(--mint);
|
||||
content: "+";
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
details[open] > summary::after { content: "−"; }
|
||||
.advanced-settings > summary { display: flex; justify-content: space-between; gap: 10px; }
|
||||
.advanced-settings > summary small, .utility-details > summary small { color: var(--muted); font-size: .57rem; font-weight: 500; }
|
||||
.advanced-settings__body { display: grid; gap: 14px; padding: 11px; border-top: 1px solid var(--line); }
|
||||
.advanced-group h3 { margin: 0 0 8px; color: #cfe2db; font-size: .66rem; }
|
||||
#array-canvas { display: block; width: 100%; height: 180px; border-top: 1px solid var(--line); background: #07110f; }
|
||||
#array-meta { margin: 0; padding: 8px; color: var(--muted); font: 600 .58rem/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.output-card { padding: 0; }
|
||||
.output-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 10px 10px 0; border-top: 1px solid var(--line); }
|
||||
.output-save-state { margin: 7px 0 0; padding: 0 10px 10px; color: var(--muted); font-size: .6rem; text-align: center; }
|
||||
.output-save-state.is-dirty { color: var(--amber); }
|
||||
|
||||
.validation-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #0b1714;
|
||||
}
|
||||
.validation-dot { width: 9px; height: 9px; flex: 0 0 auto; margin-top: 3px; border-radius: 50%; background: var(--muted); box-shadow: 0 0 0 4px rgb(145 170 162 / 8%); }
|
||||
.validation-card strong { font-size: .7rem; }
|
||||
.validation-card p { margin: 3px 0 0; color: var(--muted); font-size: .62rem; line-height: 1.45; }
|
||||
.validation-card.is-valid .validation-dot { background: var(--mint); box-shadow: 0 0 0 4px rgb(101 242 191 / 10%); }
|
||||
.validation-card.is-invalid .validation-dot { background: var(--danger); box-shadow: 0 0 0 4px rgb(255 123 115 / 10%); }
|
||||
.validation-card.is-warning .validation-dot { background: var(--amber); box-shadow: 0 0 0 4px rgb(255 197 110 / 10%); }
|
||||
|
||||
.scene-workspace { min-width: 0; padding: 16px clamp(16px, 2vw, 28px) 36px; }
|
||||
.workspace-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
||||
.workspace-heading h1 { margin: 2px 0 0; font-size: clamp(1.25rem, 2vw, 1.65rem); letter-spacing: -.035em; }
|
||||
.workspace-meta { max-width: 64%; margin: 0; color: var(--muted); font-size: .68rem; line-height: 1.45; text-align: right; }
|
||||
|
||||
.run-stage { padding: 13px; border: 1px solid var(--line); border-radius: 14px; background: rgb(255 255 255 / 2%); }
|
||||
.mode-grid { display: grid; grid-template-columns: minmax(210px, .72fr) minmax(430px, 1.28fr); gap: 9px; margin-top: 11px; }
|
||||
.mode-card { min-width: 0; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: #0b1714; }
|
||||
.mode-card--snapshot { display: flex; flex-direction: column; border-color: rgb(101 242 191 / 22%); }
|
||||
.mode-card--moving { border-color: rgb(88 199 232 / 24%); }
|
||||
.mode-heading { align-items: flex-start; }
|
||||
.mode-heading h3 { margin: 2px 0 0; font-size: .88rem; }
|
||||
.mode-card > p { margin: 7px 0 10px; color: var(--muted); font-size: .64rem; line-height: 1.45; }
|
||||
.mode-format { padding: 4px 7px; border-radius: 999px; color: var(--muted); background: rgb(255 255 255 / 4%); font-size: .57rem; }
|
||||
.mode-actions { margin-top: auto; }
|
||||
.mode-card--snapshot .mode-actions { padding-top: 8px; }
|
||||
.live-actions { justify-content: flex-start; }
|
||||
.live-controls { display: grid; grid-template-columns: minmax(150px, 1fr) 74px 64px; align-items: end; gap: 8px; margin-top: 9px; }
|
||||
.live-scrubber, .compact-select { display: grid; gap: 5px; color: var(--muted); font-size: .59rem; font-weight: 650; }
|
||||
.live-scrubber input { width: 100%; accent-color: var(--cyan); }
|
||||
.mode-help { margin-top: 8px; border-color: rgb(88 199 232 / 15%); }
|
||||
.mode-help > summary { padding-top: 7px; padding-bottom: 7px; font-size: .58rem; }
|
||||
.live-note { margin: 0; padding: 0 9px 9px; color: var(--muted); font-size: .59rem; line-height: 1.5; }
|
||||
.live-panel.is-ready { border-color: rgb(101 242 191 / 48%); }
|
||||
.live-panel.is-playing { border-color: var(--cyan); box-shadow: inset 3px 0 0 var(--cyan); }
|
||||
html[data-run-state="running"] #snapshot-mode { border-color: var(--mint); box-shadow: inset 3px 0 0 var(--mint); }
|
||||
|
||||
.inspect-stage { margin-top: 14px; }
|
||||
.inspect-heading { align-items: center; margin-bottom: 10px; }
|
||||
.result-chip { max-width: 46%; overflow: hidden; padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: .62rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.scene-canvas-card { position: relative; overflow: hidden; min-height: 500px; border: 1px solid var(--line); border-radius: 16px; background: #07110f; box-shadow: var(--shadow); }
|
||||
#scene-canvas { display: block; width: 100%; height: clamp(500px, 66vh, 820px); }
|
||||
.canvas-message { position: absolute; top: 26px; left: 50%; display: grid; width: min(390px, calc(100% - 40px)); gap: 5px; color: var(--muted); text-align: center; pointer-events: none; transform: translateX(-50%); }
|
||||
.canvas-message strong { color: #cfe2db; font-size: .8rem; }
|
||||
.canvas-message span { font-size: .65rem; }
|
||||
.canvas-message.is-hidden { display: none; }
|
||||
.canvas-badge { position: absolute; right: 12px; bottom: 12px; max-width: calc(100% - 24px); overflow: hidden; padding: 6px 9px; border: 1px solid rgb(255 255 255 / 10%); border-radius: 999px; color: #d7e8e2; background: rgb(8 17 15 / 82%); backdrop-filter: blur(10px); font: 650 .64rem ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; }
|
||||
.overlay-toolbar { justify-content: flex-end; margin-top: 8px; padding: 9px 10px; border: 1px solid var(--line); border-radius: 11px; background: rgb(255 255 255 / 2%); }
|
||||
.band-legend { display: flex; flex: 1 1 300px; flex-wrap: wrap; gap: 6px; }
|
||||
.legend-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: #0b1714; cursor: pointer; font-size: .62rem; }
|
||||
.legend-toggle input { accent-color: var(--band-color, var(--mint)); }
|
||||
.legend-swatch { width: 8px; height: 8px; border-radius: 50%; background: var(--band-color, var(--mint)); }
|
||||
.range-field, .check-field { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: .63rem; font-weight: 650; }
|
||||
.range-field input { width: 92px; accent-color: var(--mint); }
|
||||
.check-field input { width: 15px; height: 15px; accent-color: var(--mint); }
|
||||
|
||||
.result-section { margin-top: 10px; padding: 13px; border: 1px solid var(--line); border-radius: 13px; background: rgb(255 255 255 / 2%); }
|
||||
.empty-result { margin-top: 9px; padding: 12px; border: 1px dashed var(--line); border-radius: 9px; color: var(--muted); font-size: .68rem; line-height: 1.5; text-align: center; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(125px, 1fr)); gap: 8px; margin-top: 9px; }
|
||||
.metric-card { min-width: 0; padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: #0b1714; }
|
||||
.metric-card span { display: block; margin-bottom: 5px; color: var(--muted); font-size: .6rem; }
|
||||
.metric-card strong { display: block; overflow: hidden; font-size: 1rem; letter-spacing: -.03em; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.metric-card small { display: block; margin-top: 4px; color: var(--muted); font-size: .57rem; }
|
||||
.history-section, .help-panel { margin-top: 9px; border-radius: 11px; }
|
||||
.history-section > summary, .help-panel > summary { display: flex; justify-content: space-between; gap: 12px; padding: 10px 34px 10px 11px; }
|
||||
.history-body { padding: 0 11px 11px; border-top: 1px solid var(--line); }
|
||||
.history-actions { justify-content: flex-end; padding-top: 8px; }
|
||||
.section-copy { margin: 5px 0 0; color: var(--muted); font-size: .62rem; line-height: 1.45; }
|
||||
.history-list { display: grid; gap: 7px; margin-top: 9px; }
|
||||
.history-empty { padding: 10px; color: var(--muted); font-size: .65rem; text-align: center; }
|
||||
.history-card { display: grid; grid-template-columns: minmax(140px, 1.5fr) repeat(4, minmax(72px, .72fr)); gap: 8px; align-items: center; padding: 10px; border: 1px solid var(--line); border-radius: 9px; background: #0b1714; }
|
||||
.history-card strong, .history-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.history-card strong { font-size: .69rem; }
|
||||
.history-card span { color: var(--muted); font-size: .6rem; }
|
||||
.help-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; padding: 10px; border-top: 1px solid var(--line); }
|
||||
.help-grid p { margin: 0; padding: 9px; border-radius: 8px; color: var(--muted); background: #0b1714; font-size: .61rem; line-height: 1.45; }
|
||||
.help-grid strong { display: block; margin-bottom: 3px; color: var(--text); font-size: .63rem; }
|
||||
|
||||
.is-hidden { display: none !important; }
|
||||
|
||||
@media (max-width: 1240px) {
|
||||
.camera-layout { grid-template-columns: minmax(300px, 330px) minmax(560px, 1fr); }
|
||||
.mode-grid { grid-template-columns: 1fr; }
|
||||
.mode-card--snapshot { display: grid; grid-template-columns: 1fr auto; gap: 0 12px; }
|
||||
.mode-card--snapshot > p { grid-column: 1; }
|
||||
.mode-card--snapshot .mode-actions { grid-column: 2; grid-row: 1 / 3; align-self: center; min-width: 160px; padding: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.camera-layout { grid-template-columns: minmax(0, 1fr); }
|
||||
.control-panel, .scene-workspace { width: 100%; min-width: 0; }
|
||||
.control-panel { position: static; overflow: visible; max-height: none; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.scene-workspace { padding-top: 14px; }
|
||||
.control-panel .workflow-card { max-width: none; }
|
||||
.input-card { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
|
||||
.input-card > .step-heading, .input-card > .input-file-grid { grid-column: 1 / -1; }
|
||||
.input-summary, .array-card { margin-top: 12px; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.camera-header { grid-template-columns: 1fr auto; }
|
||||
.header-status { grid-column: 1 / -1; grid-row: 2; justify-content: flex-start; }
|
||||
.run-status { max-width: calc(100vw - 150px); }
|
||||
.workflow-strip { top: 92px; padding: 0; }
|
||||
.workflow-strip a { min-width: 0; padding: 8px; }
|
||||
.workflow-strip small { display: none; }
|
||||
.camera-layout { min-height: calc(100vh - 137px); }
|
||||
.input-card { display: block; }
|
||||
.workspace-heading, .inspect-heading { align-items: flex-start; flex-direction: column; }
|
||||
.workspace-meta { max-width: none; text-align: left; }
|
||||
.result-chip { max-width: 100%; }
|
||||
.mode-card--snapshot { display: flex; }
|
||||
.live-controls { grid-template-columns: 1fr 1fr; }
|
||||
.live-scrubber { grid-column: 1 / -1; }
|
||||
.scene-canvas-card { min-height: 380px; }
|
||||
#scene-canvas { height: 420px; }
|
||||
.scene-canvas-card { min-height: 400px; }
|
||||
#scene-canvas { height: 460px; }
|
||||
.help-grid { grid-template-columns: 1fr; }
|
||||
.history-card { grid-template-columns: 1fr 1fr; }
|
||||
.history-card strong { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
.field-grid, .band-editor, .output-actions, .live-controls { grid-template-columns: 1fr; }
|
||||
.live-scrubber { grid-column: auto; }
|
||||
.band-editor__remove { min-height: 34px; }
|
||||
|
||||
@media (max-width: 440px) {
|
||||
.camera-header .app-brand span, .camera-header .status-chip { display: none; }
|
||||
.header-status { grid-column: 1 / -1; }
|
||||
.run-status { max-width: 100%; }
|
||||
.workflow-strip { top: 88px; }
|
||||
.input-file-grid, .quick-field-grid, .advanced-field-grid, .output-actions, .band-editor, .live-controls { grid-template-columns: 1fr; }
|
||||
.field--wide, .live-scrubber { grid-column: auto; }
|
||||
.band-editor__remove { min-height: 32px; }
|
||||
.live-actions .button { flex: 1 1 calc(50% - 4px); }
|
||||
.metric-grid { grid-template-columns: 1fr 1fr; }
|
||||
.overlay-toolbar { justify-content: flex-start; }
|
||||
}
|
||||
|
||||
@@ -15,11 +15,24 @@
|
||||
<strong>micarrsim</strong>
|
||||
<span>Dataset Builder</span>
|
||||
</a>
|
||||
<nav class="builder-workflow" id="builder-workflow" aria-label="Scene 제작 순서">
|
||||
<ol>
|
||||
<li data-workflow-step="input"><span>1</span>입력</li>
|
||||
<li data-workflow-step="edit"><span>2</span>배치·편집</li>
|
||||
<li data-workflow-step="validate"><span>3</span>검증</li>
|
||||
<li data-workflow-step="save"><span>4</span>저장</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<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>
|
||||
<details class="header-more">
|
||||
<summary class="button button--ghost" aria-label="추가 작업">더보기</summary>
|
||||
<div class="header-more__menu">
|
||||
<button class="button button--ghost" id="wav-button" type="button" title="공간정보 없이 모든 음원을 합친 mono 확인용 WAV입니다.">Preview WAV 저장</button>
|
||||
<a class="button button--ghost" href="/apps/acoustic-camera/">Acoustic Camera 열기 →</a>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -27,39 +40,40 @@
|
||||
<section class="scene-workspace" aria-label="장면 편집 화면">
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Ground-truth canvas</p>
|
||||
<p class="eyebrow">01 · 입력과 배치</p>
|
||||
<h1 id="workspace-title">음향 장면</h1>
|
||||
</div>
|
||||
<div class="workspace-tools">
|
||||
<label class="button button--ghost file-button">
|
||||
Scene 불러오기
|
||||
<input id="scene-input" type="file" accept="application/json,.json" />
|
||||
</label>
|
||||
<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">
|
||||
원본 오디오 연결
|
||||
<label class="button button--ghost file-button" title="Scene을 다시 열었을 때 원본 파일을 SHA-256으로 재연결합니다.">
|
||||
원본 재연결
|
||||
<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>
|
||||
<strong>음원을 선택하고 canvas를 클릭해 위치를 지정하세요.</strong>
|
||||
<span>배경 이미지는 선택 사항입니다.</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>
|
||||
<p class="eyebrow">02 · 음원 편집</p>
|
||||
<h2>음원과 이동 경로</h2>
|
||||
<p>카드를 선택한 뒤 canvas를 클릭하면 현재 위치 또는 선택한 경로점이 이동합니다.</p>
|
||||
</div>
|
||||
<div class="source-actions">
|
||||
<button class="button button--ghost" id="add-source-button" type="button">+ 합성 음원</button>
|
||||
@@ -73,8 +87,11 @@
|
||||
|
||||
<section class="asset-shelf">
|
||||
<div>
|
||||
<h2>로컬 assets</h2>
|
||||
<p>원본은 업로드되지 않고 브라우저에서만 처리됩니다. PCM WAV를 권장하며, 다른 codec 지원은 환경에 따라 다릅니다.<br />Scene을 다시 불러온 뒤에는 위의 <strong>원본 오디오 연결</strong>에서 같은 파일을 선택하세요.</p>
|
||||
<h2>연결된 원본 파일</h2>
|
||||
<details class="asset-help">
|
||||
<summary>파일 연결 도움말</summary>
|
||||
<p>원본은 업로드되지 않고 브라우저에서만 처리됩니다. Scene을 다시 열었다면 위의 <strong>원본 재연결</strong>에서 같은 파일을 선택하세요. PCM WAV를 권장합니다.</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="asset-list" id="asset-list"></div>
|
||||
</section>
|
||||
@@ -86,40 +103,13 @@
|
||||
<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 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>
|
||||
|
||||
<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>
|
||||
<section class="control-section control-section--primary" id="source-editor-section">
|
||||
<div class="section-title"><span>02</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>
|
||||
@@ -135,13 +125,13 @@
|
||||
</div>
|
||||
<section class="trajectory-editor" aria-label="음원 이동 경로">
|
||||
<label class="check-field trajectory-toggle"><input id="source-motion" type="checkbox" /><span>시간에 따라 음원 이동</span></label>
|
||||
<p class="section-note">경로 시간은 Scene 시작 기준입니다. 이동을 켜면 현재 위치에서 시작해 Scene 끝까지 이어지는 두 점 경로가 만들어집니다.</p>
|
||||
<p class="section-note">켜면 현재 위치부터 Scene 끝까지의 기본 경로가 만들어집니다.</p>
|
||||
<div id="trajectory-fields" class="is-hidden">
|
||||
<label class="field field--wide trajectory-preview">
|
||||
<span>경로 미리보기 · <output id="preview-time-value" for="preview-time">0.00 s</output></span>
|
||||
<input id="preview-time" type="range" min="0" max="1" step="0.01" value="0" />
|
||||
</label>
|
||||
<p class="section-note">경로점을 선택하면 canvas 클릭으로 그 점의 x/y를 옮길 수 있습니다. 흰 테두리 점은 현재 선택한 경로점입니다.</p>
|
||||
<p class="section-note">경로점을 선택하고 canvas를 클릭해 x/y를 옮기세요.</p>
|
||||
<div class="trajectory-list" id="trajectory-list"></div>
|
||||
<button class="button button--ghost button--full" id="add-keyframe-button" type="button">+ 경로점 추가</button>
|
||||
</div>
|
||||
@@ -161,7 +151,42 @@
|
||||
</section>
|
||||
|
||||
<section class="control-section">
|
||||
<div class="section-title"><span>04</span><h2>Ambient</h2></div>
|
||||
<div class="section-title"><span>설정</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>
|
||||
<label class="field field--wide"><span>길이 (s)</span><input id="scene-duration" type="number" min="0.05" max="300" step="0.05" /></label>
|
||||
<details class="inline-disclosure">
|
||||
<summary>고급 음향·믹스 설정</summary>
|
||||
<div class="field-grid">
|
||||
<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>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<details class="control-section disclosure-section">
|
||||
<summary class="section-summary"><span>범위</span><strong>표시 평면</strong><small>고급</small></summary>
|
||||
<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는 canvas 범위이며 배경이 있으면 image plane으로 저장됩니다.</p>
|
||||
</details>
|
||||
|
||||
<details class="control-section disclosure-section">
|
||||
<summary class="section-summary"><span>배경음</span><strong>Ambient</strong><small>선택 사항</small></summary>
|
||||
<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">
|
||||
@@ -169,12 +194,7 @@
|
||||
<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>
|
||||
</details>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -643,3 +643,363 @@
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Task-first Builder navigation and progressive disclosure. */
|
||||
.builder-header {
|
||||
min-height: 58px;
|
||||
padding-block: 9px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.builder-workflow {
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.builder-workflow ol {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.builder-workflow li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 680;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.builder-workflow li + li::before {
|
||||
width: 18px;
|
||||
height: 1px;
|
||||
margin: 0 7px;
|
||||
background: var(--line);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.builder-workflow li span {
|
||||
display: grid;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid rgb(101 242 191 / 34%);
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
color: var(--mint);
|
||||
font: 750 0.6rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
.header-more {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-more > summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.header-more > summary::-webkit-details-marker,
|
||||
.inline-disclosure > summary::-webkit-details-marker,
|
||||
.section-summary::-webkit-details-marker,
|
||||
.asset-help > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-more__menu {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
display: grid;
|
||||
width: max-content;
|
||||
min-width: 210px;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #0b1714;
|
||||
box-shadow: 0 18px 48px rgb(0 0 0 / 35%);
|
||||
}
|
||||
|
||||
.header-more__menu .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.builder-layout {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 370px);
|
||||
min-height: calc(100vh - 59px);
|
||||
}
|
||||
|
||||
.scene-workspace {
|
||||
padding: clamp(20px, 2.5vw, 34px);
|
||||
}
|
||||
|
||||
.workspace-heading {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.workspace-heading h1 {
|
||||
font-size: clamp(1.55rem, 3vw, 2.35rem);
|
||||
}
|
||||
|
||||
.source-strip-heading .eyebrow {
|
||||
margin-bottom: 5px;
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.source-strip-heading {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.asset-shelf {
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.asset-help {
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.asset-help summary {
|
||||
width: fit-content;
|
||||
margin-top: 4px;
|
||||
color: var(--mint);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.asset-help p {
|
||||
max-width: 520px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
top: 59px;
|
||||
max-height: calc(100vh - 59px);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.control-section--primary {
|
||||
border-color: rgb(101 242 191 / 28%);
|
||||
background: rgb(101 242 191 / 3%);
|
||||
}
|
||||
|
||||
.validation-card {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
padding: 13px 15px;
|
||||
}
|
||||
|
||||
.control-section + .validation-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.inline-disclosure {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.inline-disclosure > summary {
|
||||
padding: 12px 0 0;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.inline-disclosure > summary::after {
|
||||
float: right;
|
||||
color: var(--mint);
|
||||
content: "+";
|
||||
}
|
||||
|
||||
.inline-disclosure[open] > summary::after {
|
||||
content: "−";
|
||||
}
|
||||
|
||||
.disclosure-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.section-summary {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 15px 17px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.section-summary > span {
|
||||
color: var(--mint);
|
||||
font: 750 0.62rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
.section-summary > strong {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.section-summary > small {
|
||||
color: var(--muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.disclosure-section[open] {
|
||||
padding: 0 17px 17px;
|
||||
}
|
||||
|
||||
.disclosure-section[open] .section-summary {
|
||||
margin: 0 -17px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.builder-workflow li {
|
||||
font-size: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.builder-workflow li + li::before {
|
||||
width: 12px;
|
||||
margin-inline: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.control-panel {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.builder-header {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.builder-workflow {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.builder-workflow ol {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.builder-workflow li {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
gap: 5px;
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.builder-workflow li + li::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.header-actions > .button,
|
||||
.header-actions > .header-more,
|
||||
.header-more > summary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-more__menu {
|
||||
top: calc(100% + 6px);
|
||||
}
|
||||
|
||||
.asset-shelf {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.builder-header,
|
||||
.scene-workspace,
|
||||
.control-panel {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.builder-workflow li {
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
font-size: 0.59rem;
|
||||
}
|
||||
|
||||
.builder-workflow li span {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-actions .button {
|
||||
min-height: 36px;
|
||||
padding-inline: 7px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.header-more__menu {
|
||||
right: -2px;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.canvas-card {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
#scene-canvas {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.workspace-tools .button,
|
||||
.source-actions .button {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.trajectory-row {
|
||||
grid-template-columns: 34px repeat(2, minmax(0, 1fr)) 30px;
|
||||
grid-template-rows: repeat(2, auto);
|
||||
}
|
||||
|
||||
.trajectory-row__select,
|
||||
.trajectory-row__remove {
|
||||
grid-row: 1 / 3;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.trajectory-row .field:nth-of-type(1) {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.trajectory-row .field:nth-of-type(2) {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.trajectory-row .field:nth-of-type(3) {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.trajectory-row .field:nth-of-type(4) {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.trajectory-row__remove {
|
||||
grid-column: 4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# 음향 환경과 마이크 응답 모델
|
||||
|
||||
micarrsim의 음향 합성은 마이크 어레이 배치와 위치추정 알고리즘의 상대 성능을 빠르게 비교하기
|
||||
위한 모델이다. ADC나 마이크 전자회로까지 재현하는 하드웨어 시뮬레이터가 아니며, 자유음장과
|
||||
계산량이 제한된 실내 희소 경로 근사 중 하나를 선택한다.
|
||||
|
||||
## 신호 처리 범위
|
||||
|
||||
Snapshot 한 번의 처리 순서는 다음과 같다.
|
||||
|
||||
1. tone, noise 또는 audio asset으로 음원 신호를 만든다.
|
||||
2. 음원과 각 마이크 사이의 직접음 또는 희소 multipath 전달함수를 만든다.
|
||||
3. 마이크별 `sensitivityDb`, `phaseOffsetDeg`와 `frequencyResponse`를 수신 신호에 적용한다.
|
||||
4. 합성된 동기식 다채널 신호를 STFT하고 conventional DAS map을 계산한다.
|
||||
5. 설정에 따라 알려진 마이크 응답을 DAS에서 역보정하고 peak와 평가 지표를 구한다.
|
||||
|
||||
Scene의 `acoustics`를 생략하거나 `model: "free-field"`로 지정하면 기존 직접음 결과를
|
||||
유지한다. `model: "shoebox"`는 직육면체 방 안에서 경로 수가 제한된 근사를 추가한다.
|
||||
|
||||
## 자유음장
|
||||
|
||||
음원과 마이크 사이 거리 `r`, 음속 `c`에 대해 직접음의 지연은 `r / c`, 진폭은
|
||||
최소 거리 clamp를 적용한 `1 / r`이다. 분수 sample 지연은 zero-padded FFT의 주파수 영역
|
||||
위상 이동으로 계산한다.
|
||||
|
||||
이 모델은 반사가 거의 없는 조건의 기준선과 배열 자체의 aperture·형상·대역 효과를 비교할 때
|
||||
적합하다.
|
||||
|
||||
## Shoebox 희소 경로 근사
|
||||
|
||||
`shoebox` 모델은 source–microphone 쌍마다 다음 경로만 만든다.
|
||||
|
||||
- 직접음 1개. 축 정렬 occluder와 교차하면 `transmissionDb`만큼 감쇠한다.
|
||||
- 방의 여섯 면마다 최대 1개의 1차 image-source 반사.
|
||||
- 반사 에너지의 일부를 0.2–2 ms의 결정적 지연으로 나누는 scattering 경로.
|
||||
- 직접음을 막는 occluder마다 여덟 corner 중 최단 우회 거리로 만든 회절 경로 1개.
|
||||
- `tapCount`로 수를 제한하고 RT60에 걸쳐 -60 dB로 감쇠하는 결정적 diffuse tail.
|
||||
|
||||
같은 Scene seed와 source/microphone id에서는 scattering과 tail이 재현된다. 모든 희소 경로는
|
||||
source–microphone 쌍의 주파수 전달함수 하나로 먼저 합쳐지고 IFFT는 한 번만 수행된다. 경로 위상은
|
||||
recurrence로 갱신하고 1,024 bin마다 다시 고정해 bin마다 삼각함수를 반복하지 않는다.
|
||||
|
||||
Scene JSON의 사용자 설정 예는 다음과 같다.
|
||||
|
||||
```json
|
||||
{
|
||||
"acoustics": {
|
||||
"model": "shoebox",
|
||||
"room": {
|
||||
"min": [-3.0, -2.5, -0.5],
|
||||
"max": [3.0, 2.5, 4.0]
|
||||
},
|
||||
"reflections": {
|
||||
"order": 1,
|
||||
"wallGainDb": -8,
|
||||
"perWallDb": {
|
||||
"zMin": -14
|
||||
},
|
||||
"scattering": 0.25
|
||||
},
|
||||
"diffuseReverb": {
|
||||
"enabled": true,
|
||||
"rt60Seconds": 0.4,
|
||||
"gainDb": -20,
|
||||
"tapCount": 6
|
||||
},
|
||||
"occluders": [
|
||||
{
|
||||
"id": "screen",
|
||||
"min": [-0.4, -0.1, 0.6],
|
||||
"max": [0.4, 0.1, 1.8],
|
||||
"transmissionDb": -18,
|
||||
"diffractionGainDb": -12
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`room`은 shoebox에서 필수다. 정적 source, trajectory keyframe과 occluder는 room 안에 있어야
|
||||
하며 occluder id는 중복될 수 없다. 사용자 설정에서도 마이크 좌표가 room 안에 놓이도록 구성해야
|
||||
1차 반사가 생성된다.
|
||||
|
||||
## Camera 환경 preset과 비용
|
||||
|
||||
Camera의 **고급 설정 → 음향 환경**은 현재 geometry 전체가 들어가도록 room을 자동 추론하고 다음
|
||||
preset을 적용한다.
|
||||
|
||||
| 선택 | 벽 gain | scattering | RT60 / tail gain / taps | 수신점당 최대 경로 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 자유음장 | — | — | — | 1 |
|
||||
| 흡음된 실내 | -14 dB | 0.15 | 0.2 s / -26 dB / 4 | 17 |
|
||||
| 일반 실내 | -8 dB | 0.25 | 0.4 s / -20 dB / 6 | 19 |
|
||||
| 반사 강한 실내 | -4 dB | 0.40 | 0.8 s / -14 dB / 10 | 23 |
|
||||
|
||||
표의 상한은 직접음 1개, 반사 6개, scattering 6개와 tail tap 수를 합한 값이다. 사용자 occluder는
|
||||
각 source–microphone 직접 경로를 실제로 막을 때 하나씩 추가된다. 대략적인 합성 경로 예산은
|
||||
`음원 수 × 마이크 수 × 수신점당 최대 경로`다. Camera는 실행 전에 이 예산을 표시한다.
|
||||
|
||||
반사·tail을 늘리면 beamforming의 `O(scan points × microphones × selected bins × STFT frames)`
|
||||
뿐 아니라 신호 합성의 전달함수 누산도 늘어난다. 처음에는 50×50 grid와 자유음장으로 기준선을
|
||||
만들고, 같은 입력에서 preset을 하나씩 바꾸어 비교하는 것이 좋다.
|
||||
|
||||
## 마이크 주파수 응답
|
||||
|
||||
각 마이크에는 2–64개의 측정점을 지정할 수 있다. `frequencyHz`는 0–1,000,000 Hz에서
|
||||
중복 없이 오름차순이어야 하며 `gainDb` 범위는 -120–120 dB, 선택적 `phaseDeg` 범위는
|
||||
-180–180°다.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "mic-001",
|
||||
"position": [-0.225, 0.225, 0],
|
||||
"sensitivityDb": 0.6,
|
||||
"phaseOffsetDeg": -1.5,
|
||||
"frequencyResponse": [
|
||||
{ "frequencyHz": 100, "gainDb": -8.0, "phaseDeg": -12 },
|
||||
{ "frequencyHz": 1000, "gainDb": -0.7, "phaseDeg": -2 },
|
||||
{ "frequencyHz": 8000, "gainDb": 2.1, "phaseDeg": 7 },
|
||||
{ "frequencyHz": 20000, "gainDb": -5.0, "phaseDeg": 20 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
측정점 사이 gain dB와 phase는 선형 보간한다. phase는 ±180° 경계에서 가장 짧은 방향으로
|
||||
보간하고 측정 범위 밖에서는 가장 가까운 끝점 값을 유지한다. 합성기는 이 curve를 실제 마이크
|
||||
채널에 적용한다.
|
||||
|
||||
Experiment에서 `beamforming.compensateFrequencyResponse`를 생략하거나 `true`로 두면
|
||||
CPU와 WebGPU DAS가 선택 FFT bin에서 알려진 curve의 역 gain/phase를 적용한다. 응답 notch의
|
||||
무제한 역보정은 noise까지 크게 증폭할 수 있으므로 양의 보상 boost는
|
||||
`beamforming.maxCompensationDb`로 제한하며 기본값은 24 dB다. 허용 범위는 0–120 dB이고
|
||||
응답 peak를 줄이는 attenuation과 phase 보상은 이 boost 상한에 의해 잘리지 않는다.
|
||||
|
||||
```json
|
||||
{
|
||||
"beamforming": {
|
||||
"algorithm": "das",
|
||||
"backend": "auto",
|
||||
"minimumDistance": 0.01,
|
||||
"normalization": "peak",
|
||||
"dynamicRangeDb": 45,
|
||||
"compensateFrequencyResponse": true,
|
||||
"maxCompensationDb": 24
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Camera의 **마이크 주파수 응답 처리**에서 **측정 curve 적용 + 보정하지 않음**을 고르면 합성에는
|
||||
curve가 남고 DAS 역보정만 꺼진다. 같은 배열에서 보정 유무의 robustness를 비교할 때 사용한다.
|
||||
curve가 없는 마이크는 0 dB, 0°로 취급한다.
|
||||
|
||||
## 결과에서 확인할 값
|
||||
|
||||
Result `signalStats`에는 다음 선택 필드가 들어간다.
|
||||
|
||||
- `acousticModel`: `free-field` 또는 `shoebox`
|
||||
- `acousticPathCount`: 실제 합성에 사용한 전체 경로 수
|
||||
- `acousticPathCounts`: `direct`, `reflection`, `scattering`,
|
||||
`diffraction`, `diffuse` 종류별 수
|
||||
|
||||
같은 입력과 seed에서 환경 preset만 바꿨다면 이 값과 위치 오차·precision/recall·PSR을 함께
|
||||
비교할 수 있다.
|
||||
|
||||
## 정확도 범위와 제외 사항
|
||||
|
||||
이 구현은 FDTD/FEM/BEM이나 room impulse response 측정치를 대신하지 않는다. 다음 항목은
|
||||
계산하지 않는다.
|
||||
|
||||
- 파동 방정식과 고차 반사
|
||||
- 재질별 주파수 의존 흡음과 복잡한 mesh 산란
|
||||
- edge 형상·파장에 따른 물리 회절
|
||||
- 음원과 마이크 지향성, 바람, Doppler
|
||||
- 절대 음압/SPL, 마이크 self-noise와 비선형 왜곡
|
||||
- preamp, anti-alias filter, ADC quantization, channel clock jitter·drift
|
||||
- 실제 장착 구조와 마이크 위치 공차
|
||||
|
||||
따라서 preset의 RT60이나 wall gain은 특정 방을 인증하는 값이 아니라 배열과 DAS가 multipath에
|
||||
얼마나 민감한지 비교하는 조건이다. 실제 장치 성능을 평가할 때는 같은 Array/scan/band 설정으로
|
||||
동기식 실측 채널을 [실시간 위치추정 코어](realtime-porting.md)에 입력하고 합성 결과와 나란히
|
||||
비교해야 한다. 주파수 응답 curve만으로 noise floor, distortion, ADC와 clock 오차까지 모사되지는
|
||||
않는다.
|
||||
+48
-3
@@ -18,8 +18,17 @@ scene JSON에는 192채널 원시 sample이나 원본 asset byte를 넣지 않
|
||||
`sha256` manifest로 참조한다. 사용자 컴퓨터의 절대 경로, URL, `..` 경로는 저장하지 않는다.
|
||||
|
||||
array의 `sensitivityDb`와 `phaseOffsetDeg`는 각 마이크 수신 channel에 적용되는 편차다. 합성기는
|
||||
이를 신호에 주입하고, beamformer는 알려진 calibration 값의 역 gain/phase를 적용한다. 따라서
|
||||
두 값이 0이 아닌 배열도 오류 없이 조용히 무시되지 않는다.
|
||||
이를 신호에 주입하고, beamformer는 알려진 calibration 값의 역 gain/phase를 적용한다. 선택적
|
||||
`frequencyResponse`는 2–64개의 `{ frequencyHz, gainDb, phaseDeg? }` 측정점이다.
|
||||
주파수는 0–1,000,000 Hz에서 중복 없이 증가해야 하며, dB와 wrapped phase를 구간 선형 보간하고
|
||||
측정 범위 밖에서는 가까운 끝점을 사용한다.
|
||||
|
||||
Experiment의 선택적 `beamforming.compensateFrequencyResponse`는 기본 `true`다. 합성
|
||||
channel에는 curve가 항상 적용되며, `true`이면 CPU/WebGPU DAS가 선택 bin에서 curve의
|
||||
역 gain/phase도 적용한다. `false`이면 sensitivity/phase calibration은 유지하고 curve
|
||||
역보정만 끈다. 선택적 `beamforming.maxCompensationDb`는 응답 notch의 역보정에서 양의
|
||||
gain boost만 제한하는 0–120 dB 값이며 생략 시 24 dB다. 응답 peak를 줄이는 attenuation과
|
||||
phase 보상은 이 boost 상한을 그대로 적용하지 않는다.
|
||||
|
||||
## 로컬 asset 재연결
|
||||
|
||||
@@ -45,6 +54,26 @@ Camera 라이브 미리보기는 sample 경계에 맞춘 시간창 중앙에서
|
||||
창으로 Scene 시간을 다시 기준화해도 tone 위상, noise sample index와 audio asset 재생 위치는 원래
|
||||
절대 시간에 이어진다. 이는 준정적 근사이며 Doppler를 계약하지 않는다.
|
||||
|
||||
## 음향 환경
|
||||
|
||||
Scene의 선택적 `acoustics.model`은 `free-field` 또는 `shoebox`다. 필드를
|
||||
생략한 문서는 free-field로 해석해 이전 직접음 결과를 유지한다. free-field에는 사용되지 않는
|
||||
`room`, `reflections`, `diffuseReverb` 또는 `occluders`를 함께 둘 수 없다.
|
||||
|
||||
shoebox에는 `room.min`과 `room.max`가 필요하다. 각 축에서 min은 max보다 작아야 하며
|
||||
정적 source와 모든 trajectory keyframe은 room 안에 있어야 한다. `reflections.order`는
|
||||
0 또는 1이고, `wallGainDb`와 선택적 면별 `perWallDb`는 -60–0 dB,
|
||||
`scattering`은 0–1이다.
|
||||
|
||||
`diffuseReverb`는 `enabled`, 0.05–5 s의 `rt60Seconds`, -60–0 dB의
|
||||
`gainDb`와 1–32의 `tapCount`를 갖는다. 축 정렬 `occluders`는 최대 8개이며
|
||||
room 안의 min/max, 고유 id, -120–0 dB의 `transmissionDb`와
|
||||
`diffractionGainDb`를 갖는다.
|
||||
|
||||
이 계약은 직접음, 여섯 면의 1차 image-source, 결정적 scattering, 막힌 occluder별 최단 corner
|
||||
우회와 제한된 RT60 tail을 위한 것이다. 재질 mesh나 고차 room impulse response 계약은 아니다.
|
||||
세부 계산과 preset은 [음향 환경과 마이크 응답 모델](acoustic-model.md)에 설명한다.
|
||||
|
||||
## 오디오와 map 정규화
|
||||
|
||||
`scene.mix.normalization`은 마이크 신호 합성 전에 적용할 오디오 mix 정책이다. `none`은 원래
|
||||
@@ -81,6 +110,11 @@ threshold와 최소 거리, sidelobe 제외 반경, half-power 비율, dynamic r
|
||||
버전과 함께 저장한다. `environment`에는 실제 backend, microphone/grid 수, scan bounds와 z,
|
||||
가능한 경우 GPU adapter 및 fallback 정보를 기록한다.
|
||||
|
||||
`signalStats`는 정규화 전 peak, scale과 clipping 수 외에 선택적으로
|
||||
`acousticModel`, 실제 전체 `acousticPathCount`와 `acousticPathCounts`를
|
||||
기록한다. 종류별 key는 `direct`, `reflection`, `scattering`,
|
||||
`diffraction`과 `diffuse`다.
|
||||
|
||||
대역별 ground truth는 tone의 주파수가 band 안에 있을 때 그 tone source를 포함한다. noise와
|
||||
asset source는 현재 모든 band의 truth로 간주한다. 즉 녹음 asset의 실제 spectrum energy를
|
||||
대역별로 판정하는 계약은 아직 없으며, 이 가정은 실제 데이터 결과를 해석할 때 고려해야 한다.
|
||||
@@ -100,8 +134,19 @@ SHA-256을 계산해 scene의 해당 asset `sha256` 필드를 채운 상태로 c
|
||||
- 중복 id와 array/scene/experiment/result 참조
|
||||
- asset 종류와 안전한 상대 경로
|
||||
- Nyquist 및 시간·scan 범위
|
||||
- 마이크 주파수 응답점의 범위·정렬과 shoebox room/occluder 포함 관계
|
||||
- map shape와 source peak 매칭
|
||||
- band별 evaluation 파라미터와 허용 metric
|
||||
- 현재 비스트리밍 CPU backend의 512 MiB channel-buffer 예산
|
||||
- Snapshot channel·최대 sparse delay FFT working set의 512 MiB 예산
|
||||
|
||||
지원하지 않는 schemaVersion은 묵시적으로 변환하지 않고 오류로 알린다.
|
||||
|
||||
실측 channel을 받는 portable API는 저장 JSON 계약과 별도로 입력 Array를 같은 규칙으로 검사한다.
|
||||
CPU 분석 계획은 기본 512 MiB, 반복 steering cache는 64 MiB, streaming ring과 분석창은 기본
|
||||
256 MiB hard cap을 할당 전에 적용한다. 채널 순서와 streaming 상태 계약은
|
||||
[실측 다채널 위치추정 코어 포팅](realtime-porting.md)을 참고한다.
|
||||
|
||||
portable block 결과의 `processingTimeMs`는 DAS와 local-peak 검출 wall time이다. Streaming
|
||||
결과는 `deadlineMs`와 `deadlineMet`을 추가하고 상태에는 최근·최대 처리 시간,
|
||||
`deadlineMisses`와 `droppedSamples`를 누적한다. 이 runtime telemetry는
|
||||
`micarrsim.result` 저장 스키마의 필수 필드가 아니다.
|
||||
|
||||
+67
-37
@@ -2,15 +2,16 @@
|
||||
|
||||
> 구현 상태 · 2026-08-28
|
||||
>
|
||||
> 단계 0–4는 완료되어 설치 없는 브라우저 앱, CPU/WebGPU DAS, Builder→Camera asset 재연결,
|
||||
> 임의 오디오·이동 trajectory, 빔 분포·복수 peak 감도, 라이브 시간축 재생, 배열 비교와
|
||||
> Result 내보내기를 71개 자동 테스트로 검증했다. 단계 5는 실제 장치·공간 모델 고도화 범위다.
|
||||
> 단계 0–4와 단계 5의 저비용 음향 환경·마이크 응답·portable streaming core까지 완료했다.
|
||||
> 설치 없는 브라우저 앱, CPU/WebGPU DAS, Builder→Camera asset 재연결, 임의 오디오·이동
|
||||
> trajectory, 빔 분포·복수 peak 감도, 라이브 시간축 재생, 배열 비교와 Result 내보내기를
|
||||
> 전체 자동 테스트로 검증했다. 실제 장치 adapter와 현장 검증은 장치 계약이 필요한 후속 범위다.
|
||||
|
||||
## 1. 목표와 성공 기준
|
||||
|
||||
이 프로젝트는 정방형 평면에 배치할 192개 MEMS 마이크의 배열을 비교하기 위한 재현 가능한
|
||||
시뮬레이터를 만든다. 첫 버전은 자유음장(free field), 점 음원, 구면파 전파를 가정하며 다음 흐름을
|
||||
끝까지 수행해야 한다.
|
||||
시뮬레이터를 만든다. 기준선은 자유음장(free field), 점 음원과 구면파 전파이며, 같은 입력에서
|
||||
마이크 응답과 계산량이 제한된 shoebox multipath를 선택해 다음 흐름을 끝까지 수행한다.
|
||||
|
||||
1. 기계음 데이터와 ambient audio를 조합해 장면 및 ground truth를 생성한다.
|
||||
2. 배열 JSON과 장면 정보를 사용해 각 마이크에 도달하는 신호를 합성한다.
|
||||
@@ -42,8 +43,8 @@ CPU 기준 구현과 GPU 결과의 수치 오차가 합의된 허용 범위 안
|
||||
seed 고정, 합성 오디오와 ground truth manifest 내보내기
|
||||
- `acoustic-camera`: manifest와 배열 JSON 읽기, 마이크 신호 전파 합성, steering vector 생성,
|
||||
DAS 실행, overlay·수치 평가·실험 결과 내보내기
|
||||
- `acoustics-core`: 두 앱이 공유하는 좌표계, 단위, 구면 감쇠·지연, STFT, 정답 rasterization,
|
||||
지표 및 CPU reference
|
||||
- `acoustics-core`: 두 앱이 공유하는 좌표계, 단위, 구면 감쇠·희소 multipath·마이크 응답,
|
||||
STFT, 정답 rasterization, 지표, CPU reference와 실측 channel용 streaming localizer
|
||||
- `webgpu-kernels`: steering/DAS 커널, buffer layout, 장치 한도 검사와 benchmark
|
||||
|
||||
## 3. 데이터 계약
|
||||
@@ -52,43 +53,62 @@ JSON Schema를 먼저 고정하고 모든 길이는 m, 시간은 s, 주파수는
|
||||
명시한다.
|
||||
|
||||
- `array.schema.json`: schema version, 배열 폭·높이, 192개 마이크의 id와 `[x,y,z]`, 선택적
|
||||
감도·채널 보정값
|
||||
감도·위상·주파수 응답 보정값
|
||||
- `scene.schema.json`: 배경 이미지 크기와 실제 평면 변환, 음원별 위치·오디오 참조·시작/종료·
|
||||
gain, ambient 참조, sample rate, seed
|
||||
gain, ambient 참조, sample rate, seed, 선택적 자유음장·shoebox 환경
|
||||
- `experiment.schema.json`: 배열/장면 참조, AC-음원 거리, 음속, frequency band 목록과 색,
|
||||
STFT window/hop, scan bounds와 해상도, 알고리즘 및 normalization
|
||||
- `result.schema.json`: 실행 환경, timing, acoustic map, peak, 평가 지표, 입력 hash
|
||||
|
||||
오디오 파일은 manifest 옆의 상대 경로로 참조한다. 브라우저 내 객체 URL은 저장 형식에 포함하지
|
||||
않는다. 스키마에는 버전을 두고 향후 잔향 모델과 마이크 지향성을 선택 필드로 추가한다.
|
||||
않는다. 스키마에는 버전을 두며, 현재 잔향은 1차 반사·scattering·RT60 tail의 희소 근사로
|
||||
계약한다. 마이크 지향성과 더 복잡한 재질·mesh 모델은 후속 선택 필드로 남긴다.
|
||||
|
||||
## 4. 음향 및 연산 모델
|
||||
|
||||
첫 모델에서 음원 `s`와 마이크 `m` 사이 거리 `r`에 대해 지연은 `r/c`, 진폭은 기준 거리와
|
||||
최소 거리 clamp를 둔 `1/r`로 계산한다. 분수 sample 지연은 주파수 영역의 phase shift로 처리한다.
|
||||
복수 음원과 ambient를 선형 합산하되 clipping 전 peak와 최종 normalization을 기록한다.
|
||||
기준 자유음장은 음원 `s`와 마이크 `m` 사이 거리 `r`에 대해 지연 `r/c`와 최소 거리
|
||||
clamp를 둔 `1/r` 진폭을 사용한다. 선택적 shoebox는 직접음, 여섯 면의 1차 image-source,
|
||||
반사 에너지의 deterministic scattering, 축 정렬 occluder의 transmission·최단 corner 우회와
|
||||
tap 수가 제한된 RT60 tail만 추가한다. 경로를 source–microphone 전달함수 하나로 합치고 IFFT를
|
||||
한 번만 수행하며 위상 recurrence로 bin별 삼각함수 호출을 줄인다.
|
||||
|
||||
마이크별 2–64점 `frequencyResponse`는 dB와 wrapped phase를 보간해 수신 channel에 적용한다.
|
||||
기본 DAS는 sensitivity/phase와 frequency curve의 알려진 역응답을 적용하되 notch의 양의 boost는
|
||||
기본 24 dB로 제한하고, curve 보정만 끈 robustness 비교도 저장 가능하다. 복수 음원과 ambient를
|
||||
선형 합산하되 clipping 전 peak, 최종 normalization, 음향 모델과 실제 종류별 경로 수를 기록한다.
|
||||
|
||||
DAS는 STFT 후 각 scan point와 마이크 사이 거리로 steering phase를 만들고, 선택된 주파수 bin의
|
||||
정렬 합 power를 계산한다. 여러 주파수 구간은 각각 독립 map과 색을 갖는다. CPU reference를 먼저
|
||||
작성한 뒤 같은 buffer layout으로 WGSL 결과를 대조한다. DAMAS, CLEAN-SC는 인터페이스만 마련하고
|
||||
초기 완료 범위에서는 제외한다.
|
||||
|
||||
연산량의 주항은 대략 `O(P × M × F)`다. `M=192`, `P=50²=2,500`일 때 주파수 bin 하나당
|
||||
연산량의 주항은 대략 `O(P × M × F × T)`다. `M=192`, `P=50²=2,500`일 때 STFT
|
||||
frame·주파수 bin 하나당
|
||||
약 48만 개의 복소 steering 누산이 필요하다. 100×100은 50×50보다 4배, 200×200은 16배이므로
|
||||
장치별 실제 GPU timing을 UI에 표시하고 목표 frame budget에 맞춰 해상도를 제한한다. 정확한 성능과
|
||||
메모리는 대표 GPU에서 benchmark하여 기록하며 이론치만으로 약속하지 않는다.
|
||||
|
||||
실측·반복 분석용 CPU 계획은 거리 행렬, bin calibration, 임시 map과 허용되는 steering matrix를
|
||||
재사용한다. 192채널·50×50·2 bins를 10회 분석하는 기준에서 거리 계산은 4,800,000회에서
|
||||
480,000회, steering pair는 9,600,000회에서 960,000회로 줄어든다. snapshot working set과 계획은
|
||||
512 MiB, steering cache는 64 MiB, streaming input은 256 MiB 기본 hard cap을 할당 전에 적용한다.
|
||||
Streaming ring은 backlog에서 가장 오래된 sample을 버리고 호출당 결과 수를 제한한다.
|
||||
각 portable 분석은 `processingTimeMs`를 기록하고 streaming은 hop 기반
|
||||
`deadlineMet`, `deadlineMisses`와 `droppedSamples`로 실제 장치의 지속
|
||||
처리 가능 여부를 측정한다. cache 수치만으로 실시간성을 보장하지 않는다.
|
||||
|
||||
## 5. UI 계획
|
||||
|
||||
`acoustic-camera`는 폭 600px 이상인 좌측 scene canvas에 기기 이미지, ground-truth hint,
|
||||
주파수별 acoustic overlay를 표시한다. 우측에는 배열 geometry를 좌표와 함께 정적 평면도로
|
||||
표시하고 그 아래에 제어 패널을 둔다. 작은 화면에서는 두 열을 세로로 쌓는다. 확대·회전 상호작용은
|
||||
향후 필요성 검증 뒤 추가한다.
|
||||
`acoustic-camera`는 **입력 → 설정 → 실행 → 확인**의 고정 작업 순서를 먼저 보여준다. 실행은
|
||||
정지 장면의 Snapshot과 trajectory의 Moving 카드로 나뉘며, 결과 canvas를 주 작업 영역으로 둔다.
|
||||
Scene·Array·Experiment·Assets 준비 상태와 실행 가능 여부를 가까이 표시하고, 비교 기록과 저장
|
||||
기능은 필요할 때 펼치는 보조 영역으로 내린다. 작은 화면에서는 한 열로 쌓는다.
|
||||
|
||||
필수 입력은 음원 수와 위치, AC 거리, 다중 주파수 구간과 색, ambient 파일, scan grid 크기,
|
||||
후처리 선택이다. 여기에 sample rate, 음속/온도, STFT window·hop, scan 영역, dynamic range,
|
||||
colormap/opacity, 음원별 gain·시작 시간, random seed, 정규화 방법을 추가한다. 잘못된 단위,
|
||||
Nyquist 초과 대역, WebGPU buffer 한도는 실행 전에 검증한다.
|
||||
일상적으로 바꾸는 backend, grid, 대역과 peak 감도는 설정 단계에 유지한다. 음속, seed, scan
|
||||
범위, STFT, 음향 환경 preset과 마이크 응답 보상은 **고급 설정**에 묶는다. 장문의 개발 요구나
|
||||
미구현 기능 홍보는 작업 화면에서 제거하고, 물리 모델·데이터 계약·포팅 설명은 문서로 연결한다.
|
||||
잘못된 단위, Nyquist 초과 대역, room 포함 관계와 CPU/GPU·신호 working-set 한도는 실행 전에
|
||||
검증한다.
|
||||
|
||||
## 6. 평가 방법
|
||||
|
||||
@@ -158,26 +178,31 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
|
||||
|
||||
완료 조건: 사용자가 코드 수정 없이 배열 JSON 두 개를 같은 장면에서 비교할 수 있다.
|
||||
|
||||
### 단계 5 — 실제 데이터와 고도화 · 향후
|
||||
### 단계 5 — 현실성·포팅과 실제 데이터 · 부분 완료
|
||||
|
||||
- 라이선스 검토를 통과한 DCASE/MIMII adapter와 batch workflow 추가
|
||||
- microphone calibration·noise 모델 검토
|
||||
- 실제 192채널 장치의 channel/clock/sample 형식이 정해진 뒤 capture·stream adapter 추가
|
||||
- 필요 시 DAMAS/CLEAN-SC를 플러그인형 후처리로 추가
|
||||
- image-source method 또는 room impulse response 기반 잔향 모듈 추가
|
||||
- [x] 마이크별 gain·phase·frequency-response 합성 및 선택적 DAS 역보정
|
||||
- [x] 자유음장을 보존하는 선택형 shoebox 1차 반사·scattering·회절·RT60 tail
|
||||
- [x] Scene 합성에서 분리한 block/streaming multichannel 위치추정 API
|
||||
- [x] 반복 거리·calibration·steering cache, bounded ring과 메모리 hard cap
|
||||
- [ ] 라이선스 검토를 통과한 DCASE/MIMII adapter와 batch workflow
|
||||
- [ ] 실제 장치의 channel/clock/sample 형식에 맞춘 capture·transport adapter와 현장 검증
|
||||
- [ ] 필요 시 센서 self-noise·위치 공차와 DAMAS/CLEAN-SC 후처리
|
||||
|
||||
완료 조건: 자유음장 결과를 훼손하지 않고 propagation model을 선택해 재실행할 수 있다.
|
||||
현재 완료 조건은 기존 자유음장 결과를 유지한 채 propagation model과 응답 보상을 선택해
|
||||
재실행하고, 같은 CPU DAS를 실측 `Float32Array` channel에 직접 적용하는 것이다. 실제
|
||||
hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정한다.
|
||||
|
||||
## 9. 주요 위험과 대응
|
||||
|
||||
- WebGPU 미지원/장치 한도: 시작 시 capability를 표시하고 CPU fallback 및 grid 상한을 둔다.
|
||||
- 좌표·phase 부호 오류: analytic tone test와 배열 대칭성 test를 먼저 만든다.
|
||||
- 브라우저 메모리: snapshot은 전체 192채널 signal buffer에 512 MiB 한도를 적용하고, 이동
|
||||
미리보기는 최대 48개·2,500 points의 짧은 시간창으로 제한한다. 실제 장시간 capture는 향후
|
||||
chunk/STFT streaming backend로 확장한다.
|
||||
- 브라우저 메모리: snapshot signal·최대 sparse delay FFT에 512 MiB 한도를 적용하고, 이동
|
||||
미리보기는 최대 48개·2,500 points의 짧은 시간창으로 제한한다. 장시간 입력은 256 MiB 기본
|
||||
한도의 fixed ring streaming core에서 처리하고 overload의 `droppedSamples`를 감시한다.
|
||||
- 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로
|
||||
쓴다. 실제 데이터 adapter는 단계 5의 라이선스 검토 뒤 추가한다.
|
||||
- 실제 AC와 simulation gap: 감도, 위상, 위치 오차를 이후 calibration/noise model로 주입 가능하게 한다.
|
||||
- 실제 AC와 simulation gap: gain·phase·주파수 응답과 희소 실내 환경을 비교 조건으로 제공하되,
|
||||
위치 공차·self-noise·ADC·clock은 실측 adapter와 현장 검증으로 다룬다.
|
||||
- post-processing이 기본 결과를 가림: raw DAS map과 후처리 map을 항상 별도로 보존한다.
|
||||
|
||||
## 10. 구현 결과와 다음 백로그
|
||||
@@ -193,11 +218,16 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
|
||||
- [x] 이동 trajectory 계약·편집·시간창 합성·Camera 라이브 재생
|
||||
- [x] 비확률 beam spread RMS contour와 Result export
|
||||
- [x] 복수 음원용 Experiment peak 검출 감도
|
||||
- [x] 마이크 주파수 응답 합성·CPU/WebGPU 역보정 비교
|
||||
- [x] 자유음장·shoebox 희소 반사/산란/회절/RT60 환경 선택
|
||||
- [x] portable block/streaming localizer와 반복 DAS cache·메모리 상한
|
||||
- [x] Camera 입력→설정→실행→확인의 Snapshot/Moving UI 재구성
|
||||
- [x] `test.cmd` one-command headless browser 검증
|
||||
- [ ] DCASE/MIMII 라이선스 검토와 adapter
|
||||
- [ ] 실제 192채널 capture/stream adapter
|
||||
- [ ] 잔향·calibration·noise model
|
||||
- [ ] 실제 192채널 capture/transport adapter와 deadline·정확도 검증
|
||||
- [ ] 센서 self-noise·위치 공차·지향성 모델
|
||||
- [ ] DAMAS/CLEAN-SC 후처리
|
||||
|
||||
정확성 test를 먼저 통과시킨 뒤 UI 또는 최적화를 진행한다는 원칙은 유지한다. 단계 5 기능은
|
||||
자유음장 DAS 결과를 바꾸지 않는 선택형 확장으로 추가한다.
|
||||
정확성 test를 먼저 통과시킨 뒤 UI 또는 최적화를 진행한다는 원칙은 유지한다. 추가 현실성 모델은
|
||||
자유음장 DAS 결과를 바꾸지 않는 선택형 확장으로, 장치 연결은 portable localizer 앞의 adapter로
|
||||
추가한다.
|
||||
|
||||
+43
-18
@@ -18,6 +18,7 @@
|
||||
<a href="#quick-start">빠른 시작</a>
|
||||
<a href="#moving-live">이동 라이브</a>
|
||||
<a href="#full-workflow">내 데이터</a>
|
||||
<a href="#acoustic-model">환경·실측</a>
|
||||
<a href="#results">결과 해석</a>
|
||||
<a href="#troubleshooting">문제 해결</a>
|
||||
</nav>
|
||||
@@ -25,11 +26,10 @@
|
||||
|
||||
<main class="guide-shell">
|
||||
<section class="guide-hero">
|
||||
<p class="eyebrow">Local user guide</p>
|
||||
<h1>설치 없이 시작하는<br />음향 카메라 실험</h1>
|
||||
<h1>micarrsim 사용 설명서</h1>
|
||||
<p>
|
||||
Windows 10/11과 최신 Chrome 또는 Edge만 있으면 됩니다. 모든 입력과 계산은 이
|
||||
컴퓨터의 브라우저 안에서 처리되며 외부 서버로 업로드되지 않습니다.
|
||||
<code>start.cmd</code>로 열고, 장면을 실행하고, 결과를 저장하는 순서만 먼저 확인하세요.
|
||||
입력과 계산은 로컬 브라우저에서 처리됩니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<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>Snapshot 카드의 <strong>▶ Snapshot 실행</strong>을 누릅니다.</li>
|
||||
<li>완료되면 <strong>Result JSON</strong>을 눌러 다운로드 폴더에 저장합니다.</li>
|
||||
</ol>
|
||||
<aside class="callout">
|
||||
@@ -59,8 +59,8 @@
|
||||
<p class="eyebrow">Moving-source demo</p>
|
||||
<h2>이동 음원을 바로 재생하기</h2>
|
||||
<ol class="guide-steps">
|
||||
<li><a href="/apps/acoustic-camera/">Acoustic Camera</a>에서 <strong>이동 예제</strong>를 누릅니다.</li>
|
||||
<li><strong>라이브 준비</strong>를 눌러 기본 2 Hz map을 계산합니다.</li>
|
||||
<li><a href="/apps/acoustic-camera/">Acoustic Camera</a>에서 <strong>이동 예제 열기</strong>를 누릅니다.</li>
|
||||
<li>Moving 카드의 <strong>Map 준비</strong>를 눌러 기본 2 Hz map을 계산합니다.</li>
|
||||
<li><strong>재생</strong>을 누르거나 시간 slider를 움직입니다.</li>
|
||||
</ol>
|
||||
<aside class="callout">
|
||||
@@ -91,7 +91,7 @@
|
||||
Assets에서 Builder에 사용한 원본 이미지와 오디오를 한 번에 다시 선택합니다.
|
||||
준비 상태가 모두 초록색인지 확인합니다.
|
||||
</li>
|
||||
<li><strong>Auto (GPU→CPU)</strong>로 실행한 뒤 Result JSON을 저장합니다.</li>
|
||||
<li>Snapshot 또는 Moving을 선택해 <strong>Auto (GPU→CPU)</strong>로 실행한 뒤 Result JSON을 저장합니다.</li>
|
||||
</ol>
|
||||
|
||||
<aside class="callout callout--important" id="asset-reconnect">
|
||||
@@ -120,7 +120,7 @@
|
||||
<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>
|
||||
<tr><td>CPU 강제</td><td>동일한 conventional DAS를 CPU에서 계산</td><td>호환성 확인·기준 비교</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -136,8 +136,29 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="guide-section" id="results">
|
||||
<section class="guide-section" id="acoustic-model">
|
||||
<div class="section-number">05</div>
|
||||
<div>
|
||||
<p class="eyebrow">Acoustics & measured input</p>
|
||||
<h2>환경과 마이크 응답 선택</h2>
|
||||
<p class="guide-copy">
|
||||
고급 설정에서 자유음장 또는 흡음·일반·반사 강한 실내 preset을 고릅니다. 실내 모델은
|
||||
1차 반사, scattering, occluder 회절과 제한된 RT60 tail을 희소 경로로 근사합니다.
|
||||
Array의 마이크 응답 curve는 수신 신호에 적용하며 DAS 역보정 유무를 비교할 수 있습니다.
|
||||
값과 한계는 <a href="/docs/acoustic-model.md">음향 모델 설명</a>에서 확인하세요.
|
||||
</p>
|
||||
<p class="guide-copy">
|
||||
실제 동기식 마이크 채널은 Scene 합성 없이 portable CPU DAS에 넣을 수 있습니다.
|
||||
장치별 channel·clock·transport adapter는 별도로 필요하며 실시간 deadline은 해당
|
||||
장치에서 <code>processingTimeMs ≤ deadlineMs</code>와 miss·drop 누계로 확인해야
|
||||
합니다. <a href="/docs/realtime-porting.md">포팅 설명서</a>에 입력 형식, ring buffer,
|
||||
메모리·취소와 backpressure 계약이 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="guide-section" id="results">
|
||||
<div class="section-number">06</div>
|
||||
<div>
|
||||
<p class="eyebrow">Result JSON</p>
|
||||
<h2>무엇이 저장되나요?</h2>
|
||||
@@ -147,6 +168,7 @@
|
||||
<div><dt>beamSpread</dt><dd>half-power lobe의 power 가중 공분산과 1× RMS ellipse</dd></div>
|
||||
<div><dt>metrics</dt><dd>energy 비율, precision/recall, sidelobe, main-lobe 폭</dd></div>
|
||||
<div><dt>evaluation</dt><dd>검출 threshold, 반경, tolerance 등 지표 계산 조건</dd></div>
|
||||
<div><dt>signalStats</dt><dd>normalization·clipping과 실제 음향 모델·종류별 sparse path 수</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>
|
||||
@@ -169,7 +191,7 @@
|
||||
</section>
|
||||
|
||||
<section class="guide-section" id="troubleshooting">
|
||||
<div class="section-number">06</div>
|
||||
<div class="section-number">07</div>
|
||||
<div>
|
||||
<p class="eyebrow">Troubleshooting</p>
|
||||
<h2>막혔을 때 확인할 것</h2>
|
||||
@@ -198,22 +220,23 @@
|
||||
<p>두 음원이 같은 주파수 대역인지 확인하고 Peak 검출 임계값을 낮추세요. gain 차이, main-lobe 폭보다 짧은 거리, grid 간격과 sidelobe는 threshold만으로 해결되지 않을 수 있습니다.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>라이브 준비 버튼이 비활성화됩니다.</summary>
|
||||
<p>Scene에 시간 순서가 올바른 trajectory keyframe이 두 개 이상 있어야 합니다. Builder에서 시간에 따라 음원 이동을 켜거나 Camera의 이동 예제를 사용하세요.</p>
|
||||
<summary>Map 준비 버튼이 비활성화됩니다.</summary>
|
||||
<p>Scene에 시간 순서가 올바른 trajectory keyframe이 두 개 이상 있어야 합니다. Builder에서 시간에 따라 음원 이동을 켜거나 Camera의 이동 예제 열기를 사용하세요.</p>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="guide-section">
|
||||
<div class="section-number">07</div>
|
||||
<div class="section-number">08</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와 실제 192채널 capture stream은 현재 범위에 포함되지 않습니다. 결과는 실제
|
||||
장비의 절대 성능 보증이 아니라 같은 조건에서 배열을 비교하는 재현 가능한 기준입니다.
|
||||
micarrsim 1.0은 점 음원의 자유음장과 계산량을 제한한 shoebox 희소 경로, 마이크
|
||||
주파수 응답과 conventional DAS를 제공합니다. 파동 방정식·고차 반사·재질별 흡음,
|
||||
복잡한 mesh, sensor self-noise·비선형, ADC·clock, DAMAS/CLEAN-SC와 자동 장치
|
||||
capture adapter는 포함하지 않습니다. 결과는 장비의 절대 성능 보증이 아니라 같은
|
||||
조건에서 배열과 알고리즘을 비교하는 재현 가능한 기준입니다.
|
||||
</p>
|
||||
<p class="guide-copy">
|
||||
원본 asset은 브라우저 메모리에서만 읽고 서버나 외부 네트워크로 보내지 않습니다.
|
||||
@@ -226,6 +249,8 @@
|
||||
<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/acoustic-model.md"><strong>음향 모델</strong><span>마이크 응답·반사·산란·회절</span></a>
|
||||
<a href="/docs/realtime-porting.md"><strong>실측 포팅</strong><span>block·streaming 위치추정 API</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>
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
# 실측 다채널 위치추정 코어 포팅
|
||||
|
||||
`packages/acoustics-core/realtime-localizer.js`는 Scene 생성이나 음향 합성 없이 이미 sampling된
|
||||
동기식 마이크 채널을 conventional CPU DAS에 직접 넣는다. 브라우저 DOM과 외부 package에
|
||||
의존하지 않는 ES module이므로, 실제 장치 adapter 뒤에 붙이거나 다른 프로젝트로 분리할 수 있다.
|
||||
|
||||
이 API는 ADC와 transport를 제어하지 않는다. 입력 channel을 획득·정렬·동기화한 다음 위치 map과
|
||||
대역별 local peak 후보를 계산하는 경계부터 담당한다.
|
||||
|
||||
## 최소 복사 단위
|
||||
|
||||
직접 경로로 `realtime-localizer.js`를 import한다면 다음 여덟 파일과 상대 경로를 함께
|
||||
복사하면 된다.
|
||||
|
||||
```text
|
||||
acoustics-core/
|
||||
realtime-localizer.js
|
||||
das.js
|
||||
stft.js
|
||||
fft.js
|
||||
math.js
|
||||
frequency-response.js
|
||||
cooperative-yield.js
|
||||
evaluation.js
|
||||
```
|
||||
|
||||
`index.js`는 전체 core의 편의 export이므로 최소 포팅에는 필요하지 않다. 현재 portable API는
|
||||
CPU 기준 구현이며 Camera의 WebGPU worker나 Scene·propagation 모듈을 요구하지 않는다.
|
||||
|
||||
## 입력 계약
|
||||
|
||||
`channels`는 다음 조건을 만족해야 한다.
|
||||
|
||||
- `array.microphones`와 같은 순서·개수의 JavaScript 배열
|
||||
- 모든 원소가 `Float32Array`
|
||||
- 한 호출 또는 chunk 안에서 모든 채널의 sample 수가 동일
|
||||
- `sampleRate`가 모든 채널에서 동일하고 sample index가 같은 시각을 의미함
|
||||
- 값은 선형 진폭 sample이며, 장치 integer PCM은 adapter에서 일관된 scale의 float로 변환
|
||||
|
||||
코어는 channel timestamp, interleaved frame, endian, packet loss 또는 sample clock을 추론하지
|
||||
않는다. 잘못된 channel 순서나 채널 사이의 sample slip은 위치 오차로 그대로 나타난다.
|
||||
|
||||
Array에는 최소한 다음 정보가 필요하다.
|
||||
|
||||
```js
|
||||
const array = {
|
||||
microphones: [
|
||||
{
|
||||
id: "mic-0",
|
||||
position: [-0.10, 0.10, 0],
|
||||
sensitivityDb: 0,
|
||||
phaseOffsetDeg: 0,
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 100, gainDb: -6, phaseDeg: -5 },
|
||||
{ frequencyHz: 1000, gainDb: 0, phaseDeg: 0 },
|
||||
{ frequencyHz: 10000, gainDb: -2, phaseDeg: 8 },
|
||||
],
|
||||
},
|
||||
// 실제 capture channel 순서대로 계속
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
`frequencyResponse`는 선택 사항이다. 있으면 2–64개 점의 주파수가 엄격히 증가해야 한다.
|
||||
`compensateFrequencyResponse`의 기본값은 `true`이며, 알려진 curve와
|
||||
`sensitivityDb`/`phaseOffsetDeg`의 역보정을 steering에 포함한다. 응답 notch의
|
||||
양의 보상 boost는 top-level `maxCompensationDb` 또는
|
||||
`beamforming.maxCompensationDb`로 제한하며 기본 24 dB, 허용 범위 0–120 dB다.
|
||||
응답 peak를 줄이는 attenuation과 phase 보상은 이 boost 상한에 의해 잘리지 않는다.
|
||||
|
||||
## 공통 계획 만들기
|
||||
|
||||
Array, scan grid, band와 STFT 설정이 바뀌지 않는 동안 계획을 한 번 만들고 재사용한다.
|
||||
|
||||
```js
|
||||
import {
|
||||
createLocalizationPlan,
|
||||
localizeMultichannel,
|
||||
} from "./acoustics-core/realtime-localizer.js";
|
||||
|
||||
const plan = createLocalizationPlan({
|
||||
sampleRate: 48_000,
|
||||
array,
|
||||
scan: {
|
||||
xMin: -1,
|
||||
xMax: 1,
|
||||
yMin: -1,
|
||||
yMax: 1,
|
||||
z: 2,
|
||||
columns: 50,
|
||||
rows: 50,
|
||||
},
|
||||
bands: [
|
||||
{ id: "machine", minHz: 900, maxHz: 1100, color: "#65f2bf" },
|
||||
],
|
||||
stft: { fftSize: 512, hopSize: 256 },
|
||||
speedOfSound: 343,
|
||||
normalization: "peak",
|
||||
compensateFrequencyResponse: true,
|
||||
maxCompensationDb: 24,
|
||||
peakDetection: {
|
||||
thresholdRatio: 0.5,
|
||||
// minimumPeakDistanceM을 생략하면 scan cell 대각선 길이
|
||||
maximumDetections: 8,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
주요 검증 조건은 다음과 같다.
|
||||
|
||||
- `fftSize`는 2 이상의 2의 거듭제곱이고 `hopSize <= fftSize`
|
||||
- band는 하나 이상이며 `0 <= minHz < maxHz < sampleRate / 2`
|
||||
- scan의 `columns`와 `rows`는 양의 정수
|
||||
- `speedOfSound`는 양수
|
||||
- `normalization`은 `peak` 또는 `none`
|
||||
- `maxCompensationDb`는 0–120 dB
|
||||
- `peakDetection.thresholdRatio`는 0–1,
|
||||
`minimumPeakDistanceM`는 0 이상,
|
||||
`maximumDetections`는 1–128의 정수
|
||||
|
||||
계획은 거리 행렬, bin calibration, 임시 map과 허용되는 경우 steering matrix를 유지하는
|
||||
stateful 객체다. 같은 계획으로 두 분석을 동시에 실행하지 말고, worker마다 별도 계획을 만든다.
|
||||
Array·scan·band·STFT를 바꾸면 새 계획을 만들어야 한다.
|
||||
|
||||
portable 계획의 기본 compute batch는 scan point 256개와 STFT transform 128개 단위로 event
|
||||
loop에 양보한다. `computeOptions.pointBatchSize`와
|
||||
`computeOptions.stftTransformBatchSize`로 조정할 수 있지만, 큰 batch는 throughput과
|
||||
취소·UI 응답성의 trade-off가 있으므로 장치에서 함께 측정한다.
|
||||
|
||||
## 한 블록 분석
|
||||
|
||||
짧은 녹음이나 adapter가 이미 만든 고정 길이 frame은 `localizeMultichannel`로 처리한다.
|
||||
|
||||
```js
|
||||
const controller = new AbortController();
|
||||
|
||||
const result = await localizeMultichannel({
|
||||
plan,
|
||||
channels, // Array<Float32Array>
|
||||
signal: controller.signal,
|
||||
onProgress(progress) {
|
||||
updateProgress(progress);
|
||||
},
|
||||
});
|
||||
|
||||
for (const location of result.locations) {
|
||||
console.log(location.bandId, location.position, location.rawPower);
|
||||
}
|
||||
```
|
||||
|
||||
`result.locations`는 입력 band별 raw map의 local peak를 flat array로 담는다. 각 band
|
||||
안에서는 power가 강한 순서이며 항목에는 `bandId`, band 내 `rank`, scan
|
||||
`index`, `position`과 `rawPower`가 있다. 기본 threshold는 해당 band 최강
|
||||
peak의 50%, 최소 peak 간격은 scan cell 대각선, 최대 검출 수는 band당 8개다. 여러 대역을
|
||||
사용한다면 `bandId`로 묶어 해석한다.
|
||||
|
||||
`peakDetection`은 후처리 위치 목록만 바꾸며 `result.bands[n].rawMap`과
|
||||
`displayMap`은 그대로 보존한다. 같은 대역의 두 음원이 main lobe보다 가까우면 local
|
||||
peak가 하나로 합쳐질 수 있고, threshold를 낮추면 sidelobe 후보도 늘 수 있다.
|
||||
`sampleCount`와 `duration`도 결과에 함께 반환된다.
|
||||
|
||||
Block 결과의 `processingTimeMs`는 DAS 계산과 local-peak 검출에 걸린 monotonic wall
|
||||
time이다. capture, adapter 변환과 결과 rendering 시간은 포함하지 않으므로 end-to-end latency는
|
||||
호출자가 별도로 측정한다.
|
||||
|
||||
계획을 전달하지 않고 `localizeMultichannel({ channels, ...config })` 형태로도 호출할 수
|
||||
있지만, 매번 geometry cache를 다시 만들므로 반복·실시간 처리에는 권장하지 않는다.
|
||||
|
||||
## 연속 chunk 처리
|
||||
|
||||
`StreamingDasLocalizer` 또는 같은 factory인 `createStreamingLocalizer`는 고정 용량
|
||||
ring buffer와 분석 workspace를 갖는다.
|
||||
|
||||
```js
|
||||
import { createStreamingLocalizer } from "./acoustics-core/realtime-localizer.js";
|
||||
|
||||
const localizer = createStreamingLocalizer({
|
||||
sampleRate: 48_000,
|
||||
array,
|
||||
scan: {
|
||||
xMin: -1,
|
||||
xMax: 1,
|
||||
yMin: -1,
|
||||
yMax: 1,
|
||||
z: 2,
|
||||
columns: 50,
|
||||
rows: 50,
|
||||
},
|
||||
bands: [
|
||||
{ id: "machine", minHz: 900, maxHz: 1100, color: "#65f2bf" },
|
||||
],
|
||||
stft: { fftSize: 512, hopSize: 256 },
|
||||
analysisWindowSamples: 4_800,
|
||||
analysisHopSamples: 2_400,
|
||||
maximumBufferedSamples: 19_200,
|
||||
maximumResultsPerCall: 1,
|
||||
});
|
||||
|
||||
function onCaptureChunk(channels) {
|
||||
// append()는 동기식으로 복사하고 즉시 상태를 반환한다.
|
||||
const state = localizer.append(channels);
|
||||
if (state.droppedSamples > 0) reportBackpressure(state);
|
||||
}
|
||||
|
||||
async function analysisLoop(signal) {
|
||||
const results = await localizer.analyzeAvailable({ signal });
|
||||
for (const result of results) {
|
||||
renderMap(result.bands[0].displayMap, result.centerTime);
|
||||
if (!result.deadlineMet) {
|
||||
reportOverload(result.processingTimeMs, result.deadlineMs);
|
||||
}
|
||||
}
|
||||
const state = localizer.getState();
|
||||
if (state.deadlineMisses > 0 || state.droppedSamples > 0) reportBackpressure(state);
|
||||
}
|
||||
```
|
||||
|
||||
`push(channels, options)`는 `append` 후 `analyzeAvailable`을 한 번 호출하는
|
||||
편의 메서드다. 임의의 capture chunk 경계로 나누어 넣어도 보존된 분석창의 결과는 동일하다.
|
||||
|
||||
각 streaming 결과에는 `sequence`, `startSample`, `endSample`,
|
||||
`startTime`, `endTime`과 `centerTime`이 추가된다. sample 위치는 localizer를
|
||||
만든 뒤 받은 첫 sample을 0으로 하는 절대 index다. 또한
|
||||
`deadlineMs = analysisHopSamples / sampleRate × 1000`과
|
||||
`deadlineMet = processingTimeMs <= deadlineMs`를 반환한다.
|
||||
|
||||
## 지연과 backpressure 조정
|
||||
|
||||
서로 다른 hop이 두 층에 있다.
|
||||
|
||||
- `stft.hopSize`: 한 분석창 안에서 spectrum을 평균하는 STFT frame 간격
|
||||
- `analysisHopSamples`: 다음 위치 map을 시작하는 간격
|
||||
|
||||
`analysisWindowSamples`는 `fftSize` 이상이어야 한다. 긴 창은 안정적인 spectrum
|
||||
평균에 유리하지만 결과 지연과 연산량이 늘어난다. 주파수 bin 간격은 주로 `fftSize`로
|
||||
결정된다.
|
||||
`analysisHopSamples`는 창보다 클 수 없으며 작게 할수록 map update rate와 중첩 연산량이
|
||||
늘어난다.
|
||||
|
||||
`maximumBufferedSamples`는 최소 한 분석창 이상이어야 한다. 새 chunk가 용량을 넘으면 가장
|
||||
오래된 sample부터 버리고 `droppedSamples`, `bufferStartSample`을 갱신한다. 호출마다
|
||||
반환할 결과 수는 기본 1개이고 구성 상한은 32개다. 한 번에 긴 backlog를 모두 계산하지 않도록
|
||||
`maximumResultsPerCall`을 작게 유지하고 event loop나 worker scheduler가 다시 호출하게 한다.
|
||||
|
||||
`getState()`으로 다음 값을 감시할 수 있다.
|
||||
|
||||
- `bufferedSamples`, `availableAnalysisCount`, `bufferStartSample`
|
||||
- `totalReceivedSamples`와 `droppedSamples`
|
||||
- `analysisAttempts`, `completedAnalyses`, `failedAnalyses`
|
||||
- `analysisDeadlineMs`, `lastProcessingTimeMs`,
|
||||
`maximumProcessingTimeMs`와 `deadlineMisses`
|
||||
- `busy`, `retainedInputBytes`, 다음 `sequence`
|
||||
|
||||
`deadlineMisses`는 완료됐지만 deadline을 넘긴 결과의 누계다. 취소·계산 오류는
|
||||
`failedAnalyses`에서 별도로 확인한다.
|
||||
|
||||
`analyzeAvailable()`을 같은 instance에서 중복 호출하면 오류가 난다. 분석 중에도
|
||||
`append()`는 안전하다. 분석기는 먼저 현재 창을 별도 workspace에 복사하며, 해당 hop은
|
||||
위치추정이 성공한 뒤에만 ring에서 소비한다.
|
||||
|
||||
## 메모리와 연산 최적화
|
||||
|
||||
기본 한도는 다음과 같다.
|
||||
|
||||
- CPU 계획 전체 `maximumPlanBytes`: 512 MiB
|
||||
- steering cache `maximumSteeringCacheBytes`: 64 MiB
|
||||
- streaming 입력 `maximumInputBufferBytes`: 256 MiB
|
||||
|
||||
입력 retained byte는
|
||||
`채널 수 × (maximumBufferedSamples + analysisWindowSamples) × 4`이며 할당 전에 검사한다.
|
||||
계획은 거리 행렬, bin calibration, Hann window, Float64 FFT workspace와 임시 map을 재사용한다.
|
||||
기본 retained data가 `maximumPlanBytes`를 넘으면 계획 생성을 거절한다. 전체 steering
|
||||
matrix가 64 MiB 또는 계획 전체 한도를 넘으면 선택 bin 앞쪽부터 예산에 들어가는 만큼만 cache하고
|
||||
나머지는 고정 microphone workspace에서 필요할 때 계산한다.
|
||||
|
||||
192채널, 50×50 scan, 선택 bin 2개를 같은 계획으로 10회 분석하는 기준에서 구현상 거리 계산은
|
||||
4,800,000회에서 480,000회, steering point–microphone pair는 9,600,000회에서 960,000회로
|
||||
줄어든다. 17,280,000회의 `sin/cos` 호출을 피하고 약 16.5 MiB를 유지한다. 자동 테스트는
|
||||
부분 cache와 cache 없음의 raw/display `Float32` map 모든 원소가 bit-exact임을 확인한다.
|
||||
각 결과의 `analysisCache`에는 cache된 bin capacity와 실제 cached/uncached 사용,
|
||||
거리·steering pair, STFT transform·FFT butterfly·DAS complex MAC, workspace allocation·reuse
|
||||
수가 들어가므로 설정별 최적화 효과를 계측할 수 있다.
|
||||
|
||||
실시간 목표를 맞추는 조정 순서는 보통 다음과 같다.
|
||||
|
||||
1. scan grid를 50×50 이하로 시작한다.
|
||||
2. 실제 필요한 band만 좁게 두어 선택 FFT bin 수를 줄인다.
|
||||
3. 필요한 시간 분해능에 맞춰 `analysisHopSamples`을 정한다.
|
||||
4. 낮은 주파수 분해능이 필요하지 않다면 FFT와 분석창을 줄인다.
|
||||
5. 같은 장치에서 각 결과의 `processingTimeMs <= deadlineMs`가 지속되는지 확인한다.
|
||||
6. `getState()`의 `deadlineMisses`, backlog와 `droppedSamples`가
|
||||
증가하지 않는지 확인한다.
|
||||
7. capture와 분석을 별도 Worker/thread에 두고 결과만 UI 또는 제어 loop로 전달한다.
|
||||
|
||||
캐시 최적화와 위 수치는 반복 계산 감소의 근거이지 192채널·50×50 또는 다른 구성이 실시간이라는
|
||||
보장이 아니다. 마이크 수, scan point, 선택 bin, STFT frame에 따른 주연산은 여전히 대략
|
||||
`O(P × M × F × T)`다. `deadlineMisses` 또는 `droppedSamples`가 누적되면
|
||||
grid·band bin·FFT/분석창 또는 update rate를 줄여야 한다. deadline은 위치추정 처리량 판정이며
|
||||
capture와 출력까지 포함하는 시스템 deadline은 adapter에서 별도로 검증한다.
|
||||
|
||||
## 취소와 오류 복구
|
||||
|
||||
`localizeMultichannel`과 `analyzeAvailable`에 `AbortSignal`을 전달하면 STFT와
|
||||
DAS batch 경계에서 `AbortError`를 발생시키고 계획 lock을 해제한다.
|
||||
|
||||
Streaming 분석이 취소되거나 실패하면 `failedAnalyses`가 증가하지만 해당 hop을
|
||||
소비하지 않는다. 실패한 창은 별도 analysis workspace에 보존되며, 다음
|
||||
`analyzeAvailable()` 호출이 같은 `startSample`과 sample을 먼저 재시도한다. 계산 중
|
||||
capture overflow로 ring의 시작점이 전진해도 이 pending 창은 workspace에 남는다. 성공하면 그때
|
||||
hop을 commit하고 sequence를 증가시킨다.
|
||||
|
||||
`onResult` callback은 성공 결과와 hop을 commit한 뒤 호출된다. consumer callback의 예외는
|
||||
호출자에게 전달되지만 성공한 창을 `failedAnalyses`로 세거나 재시도 상태로 되돌리지 않는다.
|
||||
|
||||
`reset()`은 idle 상태에서 ring, 시간 원점과 통계를 초기화한다. 분석 중에는 호출할 수 없다.
|
||||
|
||||
## 장치 adapter가 책임질 부분
|
||||
|
||||
포팅 대상 시스템은 코어 앞에서 다음을 구현해야 한다.
|
||||
|
||||
- 장치 발견, sample rate·channel 수 설정과 capture lifecycle
|
||||
- interleaved integer/float frame을 channel별 `Float32Array`로 변환
|
||||
- `array.microphones`와 물리 channel 번호의 고정 mapping
|
||||
- 공통 sample clock, channel framing, timestamp와 drift/slip 감시
|
||||
- packet loss·overflow 정책과 `droppedSamples` telemetry 결합
|
||||
- 필요 시 DC 제거, anti-alias/장치 filter와 일관된 amplitude scale
|
||||
- 실측한 gain·phase·주파수 응답을 Array 정의에 연결
|
||||
- worker scheduling, `AbortController`, UI/네트워크로의 결과 전달
|
||||
|
||||
코어 뒤에서는 `locations`를 제어에 바로 쓰기 전에 현장 데이터로 검출 임계값, false peak,
|
||||
latency와 위치 오차를 검증해야 한다. 현재 `locations`는 통계적 신뢰구간이 아니라 각 band
|
||||
DAS map에서 threshold와 거리 억제를 통과한 grid local peak 후보들이다.
|
||||
+37
-70
@@ -11,90 +11,57 @@
|
||||
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="landing-header">
|
||||
<a class="landing-brand" href="/" aria-label="micarrsim 홈">micarrsim</a>
|
||||
<div class="status-row" aria-label="실행 환경">
|
||||
<span class="status-chip" id="webgpu-status">그래픽 장치 확인 중…</span>
|
||||
<span class="status-chip status-chip--neutral">파일은 이 기기에서 처리됩니다</span>
|
||||
</div>
|
||||
<a class="landing-docs" href="/docs/">사용 설명서</a>
|
||||
</header>
|
||||
|
||||
<main class="landing-shell">
|
||||
<section class="hero-card">
|
||||
<p class="eyebrow">192-channel acoustic camera laboratory</p>
|
||||
<h1>micarrsim</h1>
|
||||
<p class="hero-copy">
|
||||
합성 음향 장면을 만들고, 마이크 배열의 자유음장 응답과 DAS 음향 영상을 같은 조건에서
|
||||
비교합니다.
|
||||
</p>
|
||||
<div class="status-row" aria-label="실행 환경">
|
||||
<span class="status-chip" id="webgpu-status">그래픽 장치 확인 중…</span>
|
||||
<span class="status-chip status-chip--neutral">로컬 처리 · 외부 업로드 없음</span>
|
||||
</div>
|
||||
<section class="landing-intro" aria-labelledby="landing-title">
|
||||
<p class="eyebrow">Microphone array workbench</p>
|
||||
<h1 id="landing-title">무엇을 하시겠어요?</h1>
|
||||
<p>배열 위치추정을 바로 실행하거나, 평가에 사용할 음원 장면을 먼저 만드세요.</p>
|
||||
</section>
|
||||
|
||||
<section class="workflow-card" aria-labelledby="workflow-title">
|
||||
<div class="workflow-card__heading">
|
||||
<section class="launcher-grid" aria-label="작업 선택">
|
||||
<a class="launcher-card launcher-card--accent" href="/apps/acoustic-camera/">
|
||||
<span class="launcher-card__number" aria-hidden="true">01</span>
|
||||
<div>
|
||||
<p class="eyebrow">Local workflow</p>
|
||||
<h2 id="workflow-title">파일이 흐르는 방식</h2>
|
||||
<p class="launcher-card__kicker">바로 분석</p>
|
||||
<h2>Acoustic Camera</h2>
|
||||
<p>기본 예제 또는 내 Scene·배열 설정으로 위치추정 결과를 확인합니다.</p>
|
||||
</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>
|
||||
<span class="launcher-card__action">Camera 열기 →</span>
|
||||
</a>
|
||||
|
||||
<section class="launcher-grid" aria-label="도구 선택">
|
||||
<a class="launcher-card" href="/apps/dataset-builder/">
|
||||
<span class="launcher-card__number">01</span>
|
||||
<span class="launcher-card__number" aria-hidden="true">02</span>
|
||||
<div>
|
||||
<p class="launcher-card__kicker">평가 장면 제작</p>
|
||||
<h2>Dataset Builder</h2>
|
||||
<p>기기 이미지에 음원을 배치하고 ambient를 혼합해 재현 가능한 장면을 만듭니다.</p>
|
||||
<p>음원 파일과 이동 경로를 배치해 정답 위치가 있는 Scene을 만듭니다.</p>
|
||||
</div>
|
||||
<span class="launcher-card__action">새 Scene 만들기 →</span>
|
||||
</a>
|
||||
|
||||
<a class="launcher-card launcher-card--accent" href="/apps/acoustic-camera/">
|
||||
<span class="launcher-card__number">02</span>
|
||||
<div>
|
||||
<h2>Acoustic Camera</h2>
|
||||
<p>배열 응답과 DAS 결과를 비교하고 이동 음원의 acoustic map을 시간축으로 재생합니다.</p>
|
||||
</div>
|
||||
<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>설정 없이 라이브 재생</h2>
|
||||
<p>Camera에서 <strong>이동 예제 → 라이브 준비 → 재생</strong>만 누르면 됩니다.</p>
|
||||
<a href="/apps/acoustic-camera/">이동 예제 열기 →</a>
|
||||
</article>
|
||||
<article class="quick-start-card">
|
||||
<p class="eyebrow">내 데이터</p>
|
||||
<h2>Builder부터 끝까지</h2>
|
||||
<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/">사용 설명서</a>
|
||||
<span aria-hidden="true">·</span>
|
||||
<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>
|
||||
<details class="workflow-disclosure">
|
||||
<summary>내 파일로 분석하는 순서</summary>
|
||||
<ol class="workflow-list">
|
||||
<li><strong>Scene 제작</strong><span>Builder에서 음원과 위치 지정</span></li>
|
||||
<li><strong>파일 보관</strong><span>Scene JSON과 원본 파일을 함께 보관</span></li>
|
||||
<li><strong>Camera 입력</strong><span>Scene, 배열, 원본 파일 선택</span></li>
|
||||
<li><strong>결과 저장</strong><span>위치 map과 지표를 Result JSON으로 저장</span></li>
|
||||
</ol>
|
||||
<p class="workflow-note">
|
||||
Scene JSON에는 원본 파일이 포함되지 않습니다. 다시 열 때 같은 파일을 선택하면 SHA-256으로
|
||||
확인해 연결합니다. <a href="/docs/#asset-reconnect">자세히 보기</a>
|
||||
</p>
|
||||
</details>
|
||||
</main>
|
||||
<script type="module" src="/packages/ui/landing.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -3,11 +3,85 @@
|
||||
두 앱이 공유하는 무의존성 수치 기준 구현입니다.
|
||||
|
||||
- 결정적 tone/white/pink noise 및 오디오 asset resampling
|
||||
- 자유음장 점 음원의 `delay = r / c`, `amplitude = 1 / r` 구면 전파
|
||||
- zero-padded FFT phase shift 기반 fractional sample delay
|
||||
- 자유음장 직접음과 선택적 저비용 shoebox multipath
|
||||
- zero-padded FFT 전달함수 기반 fractional sample delay와 마이크 주파수 응답
|
||||
- radix-2 FFT, Hann STFT와 선택 bin 추출
|
||||
- scan grid 거리 cache 및 conventional frequency-domain DAS
|
||||
|
||||
## 실측 다채널 스트림 위치 추정
|
||||
|
||||
`realtime-localizer.js`는 Scene·음원 합성 없이 이미 sampling된 동기식 마이크 채널을 직접
|
||||
받습니다. 외부 package 의존성이 없는 ES module이며, 다른 프로젝트에는 `acoustics-core`
|
||||
디렉터리를 그대로 복사해 사용할 수 있습니다. 채널 순서는 `array.microphones` 순서와 같아야 하고
|
||||
각 chunk는 동일 길이의 `Float32Array` 목록이어야 합니다.
|
||||
|
||||
```js
|
||||
import { createStreamingLocalizer } from "./packages/acoustics-core/realtime-localizer.js";
|
||||
|
||||
const localizer = createStreamingLocalizer({
|
||||
sampleRate: 48_000,
|
||||
array,
|
||||
scan: { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2, columns: 50, rows: 50 },
|
||||
bands: [{ id: "machine", minHz: 900, maxHz: 1100, color: "#65f2bf" }],
|
||||
stft: { fftSize: 512, hopSize: 256 },
|
||||
maxCompensationDb: 24,
|
||||
peakDetection: { thresholdRatio: 0.5, maximumDetections: 8 },
|
||||
analysisWindowSamples: 4_800,
|
||||
analysisHopSamples: 4_800,
|
||||
maximumBufferedSamples: 19_200,
|
||||
maximumResultsPerCall: 1,
|
||||
});
|
||||
|
||||
// Capture callback: 복사 후 즉시 반환하며, 오래 밀린 sample은 oldest-first로 버립니다.
|
||||
localizer.append(channels);
|
||||
|
||||
// Analysis loop: 호출마다 설정된 상한 이하의 map만 반환합니다.
|
||||
const results = await localizer.analyzeAvailable({ signal: abortController.signal });
|
||||
for (const result of results) {
|
||||
const { position, rawPower } = result.locations[0];
|
||||
drawMarker(position, rawPower, result.centerTime);
|
||||
if (!result.deadlineMet) reportOverload(result.processingTimeMs, result.deadlineMs);
|
||||
}
|
||||
```
|
||||
|
||||
`locations`는 band 순서, 같은 band 안에서는 raw power 내림차순의 평평한 목록입니다. 각 항목은
|
||||
기존 `{ bandId, index, position, rawPower }`에 `rank`를 더하며 같은 band의 여러 local peak를
|
||||
포함할 수 있습니다. `peakDetection.thresholdRatio` 기본값은 최강 peak의 0.5배,
|
||||
`minimumPeakDistanceM` 기본값은 scan cell 대각선, `maximumDetections` 기본값은 8입니다.
|
||||
기존처럼 `locations[0]`만 읽으면 최강 후보를 그대로 얻습니다.
|
||||
|
||||
Streaming 계산이 중단되거나 실패하면 해당 분석창과 sequence를 commit하지 않고 다음 호출에서 같은
|
||||
창을 재시도합니다. 성공한 뒤에만 hop·sequence·`completedAnalyses`를 commit하며, 그 뒤 호출되는
|
||||
`onResult` callback의 예외는 `failedAnalyses`에 포함하지 않습니다. 계산 중 capture overflow가 나면
|
||||
ring의 오래된 sample은 평소와 같이 drop telemetry에 기록됩니다.
|
||||
|
||||
한 블록 결과의 `processingTimeMs`는 DAS와 local-peak 검출에 걸린 실제 시간을 기록합니다. Streaming
|
||||
결과는 `deadlineMs = analysisHopSamples / sampleRate × 1000`과 `deadlineMet`을 더하며,
|
||||
`getState()`는 최근/최대 처리 시간과 `deadlineMisses` 누계를 제공합니다. `deadlineMisses` 또는
|
||||
`droppedSamples`가 증가하면 grid·대역 bin·분석창·update rate를 장치 성능에 맞게 줄여야 합니다.
|
||||
|
||||
깊은 응답 notch의 역필터가 sensor noise까지 폭증시키지 않도록 inverse response의 양의 boost는 기본
|
||||
24 dB로 제한합니다. Experiment의 `beamforming.maxCompensationDb` 또는 portable plan의 top-level
|
||||
`maxCompensationDb`를 0~120 dB로 조절할 수 있습니다. 이 값은 boost만 제한하며 attenuation과 phase
|
||||
보정은 유지합니다. 선택 bin 전체의 gain/phase calibration byte도 plan 생성 전에 산정하므로 lazy
|
||||
cache가 나중에 `maximumPlanBytes`를 넘지 않습니다.
|
||||
|
||||
한 블록만 처리할 때는 `createLocalizationPlan()`과 `localizeMultichannel()`을 사용합니다.
|
||||
`getState()`은 absolute sample origin, buffered/dropped sample, 대기 window 수와 고정 input
|
||||
buffer byte 수를 돌려주므로 capture adapter가 지연을 감시할 수 있습니다. 실제 장치의 clock,
|
||||
channel framing과 transport는 하드웨어별 adapter 책임이며 이 모듈은 그 다음 단계부터 시작합니다.
|
||||
|
||||
반복 CPU 분석 계획은 거리 행렬, bin calibration, Hann window, Float64 FFT workspace, 임시 bin map과
|
||||
예산 내의 Float64 steering matrix를 재사용합니다. 예를 들어 192채널·50×50·2 bins를 10회 분석하면
|
||||
거리 계산은 4,800,000회에서 480,000회로, steering point/microphone pair는 9,600,000회에서
|
||||
960,000회로 줄어듭니다(sin/cos 호출 17,280,000회 회피). 이 경우 약 16.5 MiB를 유지합니다.
|
||||
|
||||
기본 64 MiB보다 전체 wide-band steering matrix가 크더라도 cache 전체를 끄지 않습니다. 선택 bin
|
||||
앞쪽부터 예산에 들어가는 만큼만 Float64로 유지하고 나머지는 고정 microphone workspace에서 계산합니다.
|
||||
`analysisCache`는 cached bin capacity, uncached 사용 횟수·pair 수, STFT transform·FFT butterfly·DAS
|
||||
complex MAC 수와 실제 workspace allocation/reuse 수를 제공합니다. 자동 테스트는 부분 cache와
|
||||
cache 없음 사이 raw/display Float32 map의 전 요소 bit-exact 일치를 고정합니다.
|
||||
|
||||
## 이동 음원과 분석 시간창
|
||||
|
||||
`sourcePositionAtTime`은 scene 시작 기준의 절대 시각에서 trajectory keyframe 사이를 선형 보간하며,
|
||||
@@ -30,11 +104,45 @@ 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` 취소가 동작합니다.
|
||||
buffer로 보존합니다. bin spectrum은 제한된 batch로 처리합니다. cooperative yield는 지원 환경에서
|
||||
`scheduler.yield()`, 그 외에는 `MessageChannel`을 사용하고 timer task에도 주기적으로 기회를 주므로
|
||||
진행 callback과 `AbortSignal` 취소를 유지하면서 연속 `setTimeout(0)` clamp를 피합니다. portable
|
||||
plan의 기본 batch는 point 256개·STFT transform 128개이며 `computeOptions`로 조정할 수 있습니다.
|
||||
|
||||
현재 전파 모델은 자유음장·무지향성 점 음원이며 잔향은 포함하지 않습니다. 전체 signal을
|
||||
메모리에 보관하는 CPU 기준 구현이므로 source/channel과 delay FFT working set을 512 MiB로 제한합니다.
|
||||
장시간 입력은 이후 chunk/STFT streaming backend에서 지원합니다.
|
||||
마이크별 `frequencyResponse`는 2~64개의 `{ frequencyHz, gainDb, phaseDeg? }` 점으로 정의합니다.
|
||||
`interpolateFrequencyResponse`는 dB와 wrapped phase를 구간 선형 보간하고 측정 범위 밖에서는 가장
|
||||
가까운 끝점을 사용합니다. 합성 시 기존 sensitivity/phase 편차에 이 응답을 추가하며, CPU DAS는
|
||||
선택 FFT bin의 역응답을 calibration/steering cache에 포함합니다. WebGPU 경로는 GPU 전송 직전의
|
||||
packed STFT에 같은 역응답을 적용합니다. `beamforming.compensateFrequencyResponse: false`는
|
||||
sensitivity/phase 보정은 유지한 채 곡선 보정만 꺼 robustness 비교를 재현합니다.
|
||||
|
||||
응답 곡선이 없는 배열은 기존 계산 경로와 결과를 유지합니다. 곡선이 있으면 합성 FFT마다 bin당
|
||||
보간·복소 곱이 추가되고, WebGPU 준비 단계에는 `선택 bin × 마이크` calibration과 STFT 복소 곱이
|
||||
추가되지만 GPU buffer layout이나 dispatch 크기는 늘어나지 않습니다.
|
||||
|
||||
## 저비용 환경 근사
|
||||
|
||||
Scene의 `acoustics`를 생략하거나 `{ "model": "free-field" }`로 두면 기존 직접음
|
||||
`delay = r/c`, `gain = 1/r`만 사용합니다. `model: "shoebox"`는 다음 경로를 선택적으로
|
||||
추가합니다.
|
||||
|
||||
- 직육면체 `room`의 여섯 면에 대한 1차 image-source 반사
|
||||
- 반사 에너지 일부를 deterministic 지연 경로로 나누는 scattering 근사
|
||||
- 축 정렬 box `occluders`를 통과하는 직접음 감쇠와 가장 짧은 corner detour 회절 경로
|
||||
- RT60·gain·tap 수로 제한한 deterministic diffuse-reverb tail
|
||||
|
||||
모든 경로의 위상과 gain은 마이크마다 하나의 frequency-domain 전달함수로 합친 뒤 IFFT를 한 번만
|
||||
수행합니다. 경로 위상은 recurrence로 갱신하고 1,024 bin마다 재고정하므로 각 bin·path마다
|
||||
삼각함수를 다시 호출하지 않습니다. UI preset도 수신점당 sparse path 상한을 표시합니다.
|
||||
|
||||
이 모델은 배열 배치와 위치추정 robustness를 빠르게 비교하기 위한 근사입니다. 파동 방정식,
|
||||
고차 반사, 재질별 주파수 의존 흡음, 복잡한 mesh 회절, Doppler, 음원·마이크 지향성, 압력/SPL,
|
||||
센서 self-noise, ADC quantization·clock jitter는 계산하지 않습니다. 그런 효과가 중요한
|
||||
검증에서는 실측 다채널 데이터를 아래 streaming API에 넣어 결과를 비교해야 합니다.
|
||||
|
||||
snapshot 합성은 전체 signal과 가장 늦은 sparse path를 포함하는 delay FFT working set을
|
||||
512 MiB로 제한합니다. 실측·장시간 입력은 위 streaming localizer의 bounded ring buffer로
|
||||
전체 녹음을 메모리에 보관하지 않고 처리할 수 있습니다. CPU analysis plan도 기본 512 MiB
|
||||
hard cap을 할당 전에 검사하며 steering cache는 별도의 64 MiB 안에서 가능한 bin만 유지합니다.
|
||||
|
||||
192채널·50×50 fixture는 브라우저 통합 테스트에서 실제 합성부터 DAS peak 검출까지 실행합니다.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
let messageChannelYield;
|
||||
let cooperativeTurn = 0;
|
||||
|
||||
export const COOPERATIVE_TIMER_TURN_INTERVAL = 8;
|
||||
|
||||
function createMessageChannelYield() {
|
||||
if (typeof globalThis.MessageChannel !== "function") return undefined;
|
||||
const channel = new globalThis.MessageChannel();
|
||||
const queue = [];
|
||||
channel.port1.onmessage = () => queue.shift()?.();
|
||||
channel.port1.unref?.();
|
||||
channel.port2.unref?.();
|
||||
return () => new Promise((resolve) => {
|
||||
queue.push(resolve);
|
||||
channel.port2.postMessage(0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield one cooperative task without relying on nested zero-delay timers.
|
||||
* scheduler.yield() is preferred when the host implements it; MessageChannel
|
||||
* keeps the fallback task-based and avoids browser timer clamping.
|
||||
*/
|
||||
export function cooperativeYield() {
|
||||
cooperativeTurn += 1;
|
||||
if (cooperativeTurn % COOPERATIVE_TIMER_TURN_INTERVAL === 0) {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
const hostScheduler = globalThis.scheduler;
|
||||
if (typeof hostScheduler?.yield === "function") {
|
||||
try {
|
||||
return hostScheduler.yield();
|
||||
} catch {
|
||||
// A partial scheduler implementation must not make computation unusable.
|
||||
}
|
||||
}
|
||||
messageChannelYield ??= createMessageChannelYield();
|
||||
if (messageChannelYield) return messageChannelYield();
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
+505
-36
@@ -1,5 +1,11 @@
|
||||
import { dbToLinear, createScanPoints, distance3, findPeak, scanIndexToPosition, TAU } from "./math.js";
|
||||
import { binsForBand, extractStftBins, getFrameCount } from "./stft.js";
|
||||
import {
|
||||
interpolateFrequencyResponse,
|
||||
inverseResponseGainDb,
|
||||
requireMaxCompensationDb,
|
||||
} from "./frequency-response.js";
|
||||
import { cooperativeYield } from "./cooperative-yield.js";
|
||||
import { binsForBand, createStftWorkspace, extractStftBins, getFrameCount } from "./stft.js";
|
||||
|
||||
function throwIfAborted(signal) {
|
||||
if (signal?.aborted) {
|
||||
@@ -9,9 +15,9 @@ function throwIfAborted(signal) {
|
||||
}
|
||||
}
|
||||
|
||||
function yieldToEventLoop() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
export const DEFAULT_MAXIMUM_CPU_STEERING_CACHE_BYTES = 64 * 1024 * 1024;
|
||||
export const DEFAULT_MAXIMUM_CPU_ANALYSIS_PLAN_BYTES = 512 * 1024 * 1024;
|
||||
const DAS_ANALYSIS_PLAN_KIND = "micarrsim.das-analysis-plan/1";
|
||||
|
||||
export function createDistanceMatrix(arrayDefinition, scan) {
|
||||
const points = createScanPoints(scan);
|
||||
@@ -31,6 +37,107 @@ export function createDistanceMatrix(arrayDefinition, scan) {
|
||||
return distances;
|
||||
}
|
||||
|
||||
function createBandIndexLookup(bandBins) {
|
||||
const lookup = new Map();
|
||||
bandBins.forEach((bins, bandIndex) => {
|
||||
bins.forEach((bin) => {
|
||||
if (!lookup.has(bin)) lookup.set(bin, []);
|
||||
lookup.get(bin).push(bandIndex);
|
||||
});
|
||||
});
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function createBinCalibration(
|
||||
arrayDefinition,
|
||||
bin,
|
||||
sampleRate,
|
||||
fftSize,
|
||||
compensateFrequencyResponse,
|
||||
maximumCompensationDb,
|
||||
) {
|
||||
const microphoneCount = arrayDefinition.microphones.length;
|
||||
const gains = new Float64Array(microphoneCount);
|
||||
const phases = new Float64Array(microphoneCount);
|
||||
const frequencyHz = (bin * sampleRate) / fftSize;
|
||||
const response = {};
|
||||
const maxCompensationDb = requireMaxCompensationDb(maximumCompensationDb);
|
||||
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
|
||||
const microphone = arrayDefinition.microphones[microphoneIndex];
|
||||
let responseGainDb = 0;
|
||||
let responsePhaseDeg = 0;
|
||||
if (compensateFrequencyResponse && microphone.frequencyResponse) {
|
||||
interpolateFrequencyResponse(microphone.frequencyResponse, frequencyHz, response);
|
||||
responseGainDb = response.gainDb;
|
||||
responsePhaseDeg = response.phaseDeg;
|
||||
}
|
||||
const responseCompensationDb = compensateFrequencyResponse
|
||||
? inverseResponseGainDb(responseGainDb, maxCompensationDb)
|
||||
: 0;
|
||||
gains[microphoneIndex] = dbToLinear(
|
||||
-(microphone.sensitivityDb ?? 0) + responseCompensationDb,
|
||||
);
|
||||
phases[microphoneIndex] =
|
||||
((microphone.phaseOffsetDeg ?? 0) + responsePhaseDeg) * Math.PI / 180;
|
||||
}
|
||||
return { gains, phases };
|
||||
}
|
||||
|
||||
function fillSteeringVector({
|
||||
angularWaveNumber,
|
||||
distances,
|
||||
distanceOffset,
|
||||
calibration,
|
||||
outputReal,
|
||||
outputImaginary,
|
||||
outputOffset,
|
||||
}) {
|
||||
for (let microphoneIndex = 0; microphoneIndex < calibration.gains.length; microphoneIndex += 1) {
|
||||
const phase =
|
||||
angularWaveNumber * distances[distanceOffset + microphoneIndex] -
|
||||
calibration.phases[microphoneIndex];
|
||||
outputReal[outputOffset + microphoneIndex] =
|
||||
Math.cos(phase) * calibration.gains[microphoneIndex];
|
||||
outputImaginary[outputOffset + microphoneIndex] =
|
||||
Math.sin(phase) * calibration.gains[microphoneIndex];
|
||||
}
|
||||
}
|
||||
|
||||
async function createSteeringMatrix(options) {
|
||||
const {
|
||||
bin,
|
||||
pointCount,
|
||||
microphoneCount,
|
||||
distances,
|
||||
sampleRate,
|
||||
fftSize,
|
||||
speedOfSound,
|
||||
calibration,
|
||||
signal,
|
||||
pointBatchSize,
|
||||
} = options;
|
||||
const real = new Float64Array(pointCount * microphoneCount);
|
||||
const imaginary = new Float64Array(pointCount * microphoneCount);
|
||||
const angularWaveNumber = (TAU * bin * sampleRate) / (fftSize * speedOfSound);
|
||||
for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
|
||||
throwIfAborted(signal);
|
||||
const offset = pointIndex * microphoneCount;
|
||||
fillSteeringVector({
|
||||
angularWaveNumber,
|
||||
distances,
|
||||
distanceOffset: offset,
|
||||
calibration,
|
||||
outputReal: real,
|
||||
outputImaginary: imaginary,
|
||||
outputOffset: offset,
|
||||
});
|
||||
if ((pointIndex + 1) % pointBatchSize === 0 && pointIndex + 1 < pointCount) {
|
||||
await cooperativeYield();
|
||||
}
|
||||
}
|
||||
return { real, imaginary };
|
||||
}
|
||||
|
||||
export async function computeBinMap(options) {
|
||||
const {
|
||||
bin,
|
||||
@@ -42,26 +149,66 @@ export async function computeBinMap(options) {
|
||||
sampleRate,
|
||||
fftSize,
|
||||
speedOfSound,
|
||||
compensateFrequencyResponse = true,
|
||||
maxCompensationDb,
|
||||
calibration: providedCalibration,
|
||||
steeringMatrix,
|
||||
output: providedOutput,
|
||||
steeringReal: providedSteeringReal,
|
||||
steeringImaginary: providedSteeringImaginary,
|
||||
signal,
|
||||
pointBatchSize,
|
||||
pointBatchSize = 64,
|
||||
} = options;
|
||||
const microphoneCount = arrayDefinition.microphones.length;
|
||||
const output = new Float32Array(pointCount);
|
||||
const steeringReal = new Float64Array(microphoneCount);
|
||||
const steeringImaginary = new Float64Array(microphoneCount);
|
||||
const output = providedOutput ?? new Float32Array(pointCount);
|
||||
const steeringReal = providedSteeringReal ?? new Float64Array(microphoneCount);
|
||||
const steeringImaginary = providedSteeringImaginary ?? new Float64Array(microphoneCount);
|
||||
const angularWaveNumber = (TAU * bin * sampleRate) / (fftSize * speedOfSound);
|
||||
const microphoneScale = 1 / microphoneCount;
|
||||
const calibration = providedCalibration ?? createBinCalibration(
|
||||
arrayDefinition,
|
||||
bin,
|
||||
sampleRate,
|
||||
fftSize,
|
||||
compensateFrequencyResponse,
|
||||
maxCompensationDb,
|
||||
);
|
||||
const phaseRealValues = steeringMatrix?.real ?? steeringReal;
|
||||
const phaseImaginaryValues = steeringMatrix?.imaginary ?? steeringImaginary;
|
||||
if (output.length !== pointCount) throw new RangeError("output length must match pointCount.");
|
||||
if (steeringReal.length !== microphoneCount || steeringImaginary.length !== microphoneCount) {
|
||||
throw new RangeError("steering workspace length must match microphone count.");
|
||||
}
|
||||
if (
|
||||
calibration.gains.length !== microphoneCount ||
|
||||
calibration.phases.length !== microphoneCount
|
||||
) {
|
||||
throw new RangeError("calibration length must match microphone count.");
|
||||
}
|
||||
if (
|
||||
steeringMatrix &&
|
||||
(
|
||||
steeringMatrix.real.length !== pointCount * microphoneCount ||
|
||||
steeringMatrix.imaginary.length !== pointCount * microphoneCount
|
||||
)
|
||||
) {
|
||||
throw new RangeError("steering matrix length must match point and microphone counts.");
|
||||
}
|
||||
|
||||
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;
|
||||
const steeringOffset = steeringMatrix ? distanceOffset : 0;
|
||||
if (!steeringMatrix) {
|
||||
fillSteeringVector({
|
||||
angularWaveNumber,
|
||||
distances,
|
||||
distanceOffset,
|
||||
calibration,
|
||||
outputReal: steeringReal,
|
||||
outputImaginary: steeringImaginary,
|
||||
outputOffset: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let accumulatedPower = 0;
|
||||
@@ -73,24 +220,272 @@ export async function computeBinMap(options) {
|
||||
const spectrumIndex = spectrumOffset + microphoneIndex;
|
||||
const inputReal = spectrum.real[spectrumIndex];
|
||||
const inputImaginary = spectrum.imaginary[spectrumIndex];
|
||||
const phaseReal = steeringReal[microphoneIndex];
|
||||
const phaseImaginary = steeringImaginary[microphoneIndex];
|
||||
const phaseReal = phaseRealValues[steeringOffset + microphoneIndex];
|
||||
const phaseImaginary = phaseImaginaryValues[steeringOffset + 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();
|
||||
if ((pointIndex + 1) % pointBatchSize === 0 && pointIndex + 1 < pointCount) {
|
||||
await cooperativeYield();
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function estimateDasWorkload(options) {
|
||||
const { channels, sampleRate, arrayDefinition, experiment } = options;
|
||||
const { fftSize, hopSize } = experiment.stft;
|
||||
/**
|
||||
* Cache geometry and optional steering matrices for repeated CPU DAS analyses.
|
||||
*
|
||||
* Plans are intentionally stateful and must not be used concurrently. They are
|
||||
* local runtime objects (Maps and workspaces), not serializable data contracts.
|
||||
*/
|
||||
export function createDasAnalysisPlan(options) {
|
||||
const {
|
||||
sampleRate,
|
||||
arrayDefinition,
|
||||
scene,
|
||||
experiment,
|
||||
maximumSteeringCacheBytes = DEFAULT_MAXIMUM_CPU_STEERING_CACHE_BYTES,
|
||||
maximumPlanBytes = DEFAULT_MAXIMUM_CPU_ANALYSIS_PLAN_BYTES,
|
||||
} = options;
|
||||
if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
|
||||
throw new RangeError("sampleRate must be a positive finite number.");
|
||||
}
|
||||
if (!Array.isArray(arrayDefinition?.microphones) || arrayDefinition.microphones.length === 0) {
|
||||
throw new TypeError("arrayDefinition.microphones must be a non-empty array.");
|
||||
}
|
||||
if (!Number.isFinite(scene?.speedOfSound) || scene.speedOfSound <= 0) {
|
||||
throw new RangeError("scene.speedOfSound must be a positive finite number.");
|
||||
}
|
||||
if (!Number.isFinite(maximumSteeringCacheBytes) || maximumSteeringCacheBytes < 0) {
|
||||
throw new RangeError("maximumSteeringCacheBytes must be a non-negative finite number.");
|
||||
}
|
||||
if (!Number.isSafeInteger(maximumPlanBytes) || maximumPlanBytes <= 0) {
|
||||
throw new RangeError("maximumPlanBytes must be a positive safe integer.");
|
||||
}
|
||||
|
||||
const { fftSize } = experiment.stft;
|
||||
const bandBins = experiment.frequencyBands.map((band) => binsForBand(band, sampleRate, fftSize));
|
||||
const selectedBins = [...new Set(bandBins.flat())].sort((a, b) => a - b);
|
||||
const microphoneCount = arrayDefinition.microphones.length;
|
||||
const pointCount = experiment.scan.columns * experiment.scan.rows;
|
||||
const pointMicrophonePairs = pointCount * microphoneCount;
|
||||
const distanceMatrixBytes = pointMicrophonePairs * Float32Array.BYTES_PER_ELEMENT;
|
||||
const stftWorkspaceBytes = fftSize * Float64Array.BYTES_PER_ELEMENT * 3;
|
||||
const calibrationBytes =
|
||||
selectedBins.length * microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2;
|
||||
const retainedBaseBytes =
|
||||
distanceMatrixBytes +
|
||||
pointCount * Float32Array.BYTES_PER_ELEMENT +
|
||||
microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2 +
|
||||
stftWorkspaceBytes;
|
||||
const retainedNonSteeringBytes = retainedBaseBytes + calibrationBytes;
|
||||
if (
|
||||
!Number.isSafeInteger(pointMicrophonePairs) ||
|
||||
!Number.isSafeInteger(retainedBaseBytes) ||
|
||||
!Number.isSafeInteger(calibrationBytes) ||
|
||||
!Number.isSafeInteger(retainedNonSteeringBytes) ||
|
||||
retainedNonSteeringBytes > maximumPlanBytes
|
||||
) {
|
||||
throw new RangeError(
|
||||
"CPU analysis plan requires " + retainedNonSteeringBytes.toLocaleString() +
|
||||
" non-steering bytes including " + calibrationBytes.toLocaleString() +
|
||||
" calibration bytes, above maximumPlanBytes " +
|
||||
maximumPlanBytes.toLocaleString() + ".",
|
||||
);
|
||||
}
|
||||
const distances = createDistanceMatrix(arrayDefinition, experiment.scan);
|
||||
const binMap = new Float32Array(pointCount);
|
||||
const steeringReal = new Float64Array(microphoneCount);
|
||||
const steeringImaginary = new Float64Array(microphoneCount);
|
||||
const stftWorkspace = createStftWorkspace(fftSize);
|
||||
const steeringBytesPerBin =
|
||||
pointCount * microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2;
|
||||
const requestedSteeringCacheBytes = steeringBytesPerBin * selectedBins.length;
|
||||
const steeringCacheBinCapacity = Math.min(
|
||||
selectedBins.length,
|
||||
Math.floor(maximumSteeringCacheBytes / steeringBytesPerBin),
|
||||
Math.floor((maximumPlanBytes - retainedNonSteeringBytes) / steeringBytesPerBin),
|
||||
);
|
||||
const steeringCacheBytes = steeringCacheBinCapacity * steeringBytesPerBin;
|
||||
const steeringCacheEnabled = steeringCacheBinCapacity > 0;
|
||||
const steeringCachedBins = new Set(selectedBins.slice(0, steeringCacheBinCapacity));
|
||||
|
||||
return {
|
||||
kind: DAS_ANALYSIS_PLAN_KIND,
|
||||
sampleRate,
|
||||
arrayDefinition,
|
||||
scene,
|
||||
experiment,
|
||||
microphoneCount,
|
||||
pointCount,
|
||||
bandBins,
|
||||
selectedBins,
|
||||
distances,
|
||||
bandIndexesByBin: createBandIndexLookup(bandBins),
|
||||
calibrations: new Map(),
|
||||
steeringMatrices: new Map(),
|
||||
steeringCacheEnabled,
|
||||
steeringCacheBinCapacity,
|
||||
steeringCachedBins,
|
||||
steeringBytesPerBin,
|
||||
steeringCacheBytes,
|
||||
requestedSteeringCacheBytes,
|
||||
retainedBaseBytes,
|
||||
calibrationBytes,
|
||||
retainedNonSteeringBytes,
|
||||
maximumSteeringCacheBytes,
|
||||
maximumPlanBytes,
|
||||
binMap,
|
||||
steeringReal,
|
||||
steeringImaginary,
|
||||
stftWorkspace,
|
||||
busy: false,
|
||||
statistics: {
|
||||
analysisAttempts: 0,
|
||||
completedAnalyses: 0,
|
||||
distanceMatrixBuilds: 1,
|
||||
distanceEvaluations: pointCount * microphoneCount,
|
||||
avoidedDistanceEvaluations: 0,
|
||||
calibrationBuilds: 0,
|
||||
calibrationCacheHits: 0,
|
||||
steeringMatrixBuilds: 0,
|
||||
steeringCacheHits: 0,
|
||||
uncachedSteeringUses: 0,
|
||||
steeringPointMicrophonePairs: 0,
|
||||
uncachedSteeringPointMicrophonePairs: 0,
|
||||
avoidedSteeringPointMicrophonePairs: 0,
|
||||
stftWindowBuilds: 1,
|
||||
stftWorkspaceAllocations: 1,
|
||||
stftWorkspaceUses: 0,
|
||||
avoidedStftWindowBuilds: 0,
|
||||
avoidedStftWorkspaceAllocations: 0,
|
||||
stftTransforms: 0,
|
||||
fftButterflies: 0,
|
||||
beamformingComplexMacs: 0,
|
||||
binMapAllocations: 1,
|
||||
steeringWorkspaceAllocations: 1,
|
||||
binMapUses: 0,
|
||||
avoidedBinMapAllocations: 0,
|
||||
retainedBytes: retainedBaseBytes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertCompatibleAnalysisPlan(plan, options) {
|
||||
if (plan?.kind !== DAS_ANALYSIS_PLAN_KIND) {
|
||||
throw new TypeError("analysisPlan was not created by createDasAnalysisPlan().");
|
||||
}
|
||||
if (
|
||||
plan.sampleRate !== options.sampleRate ||
|
||||
plan.arrayDefinition !== options.arrayDefinition ||
|
||||
plan.scene !== options.scene ||
|
||||
plan.experiment !== options.experiment
|
||||
) {
|
||||
throw new Error("analysisPlan must be used with the exact configuration objects that created it.");
|
||||
}
|
||||
}
|
||||
|
||||
function planCalibration(plan, bin) {
|
||||
let calibration = plan.calibrations.get(bin);
|
||||
if (calibration) {
|
||||
plan.statistics.calibrationCacheHits += 1;
|
||||
return calibration;
|
||||
}
|
||||
calibration = createBinCalibration(
|
||||
plan.arrayDefinition,
|
||||
bin,
|
||||
plan.sampleRate,
|
||||
plan.experiment.stft.fftSize,
|
||||
plan.experiment.beamforming.compensateFrequencyResponse !== false,
|
||||
plan.experiment.beamforming.maxCompensationDb,
|
||||
);
|
||||
plan.calibrations.set(bin, calibration);
|
||||
plan.statistics.calibrationBuilds += 1;
|
||||
plan.statistics.retainedBytes += calibration.gains.byteLength + calibration.phases.byteLength;
|
||||
return calibration;
|
||||
}
|
||||
|
||||
async function planSteeringMatrix(plan, bin, calibration, signal, pointBatchSize) {
|
||||
if (!plan.steeringCachedBins.has(bin)) {
|
||||
plan.statistics.uncachedSteeringUses += 1;
|
||||
plan.statistics.uncachedSteeringPointMicrophonePairs +=
|
||||
plan.pointCount * plan.microphoneCount;
|
||||
return undefined;
|
||||
}
|
||||
let matrix = plan.steeringMatrices.get(bin);
|
||||
if (matrix) {
|
||||
plan.statistics.steeringCacheHits += 1;
|
||||
plan.statistics.avoidedSteeringPointMicrophonePairs +=
|
||||
plan.pointCount * plan.microphoneCount;
|
||||
return matrix;
|
||||
}
|
||||
matrix = await createSteeringMatrix({
|
||||
bin,
|
||||
pointCount: plan.pointCount,
|
||||
microphoneCount: plan.microphoneCount,
|
||||
distances: plan.distances,
|
||||
sampleRate: plan.sampleRate,
|
||||
fftSize: plan.experiment.stft.fftSize,
|
||||
speedOfSound: plan.scene.speedOfSound,
|
||||
calibration,
|
||||
signal,
|
||||
pointBatchSize,
|
||||
});
|
||||
plan.steeringMatrices.set(bin, matrix);
|
||||
plan.statistics.steeringMatrixBuilds += 1;
|
||||
plan.statistics.steeringPointMicrophonePairs +=
|
||||
plan.pointCount * plan.microphoneCount;
|
||||
plan.statistics.retainedBytes += matrix.real.byteLength + matrix.imaginary.byteLength;
|
||||
return matrix;
|
||||
}
|
||||
|
||||
function analysisCacheSummary(plan) {
|
||||
return {
|
||||
distanceMatrixBuilds: plan.statistics.distanceMatrixBuilds,
|
||||
distanceEvaluations: plan.statistics.distanceEvaluations,
|
||||
avoidedDistanceEvaluations: plan.statistics.avoidedDistanceEvaluations,
|
||||
calibrationBuilds: plan.statistics.calibrationBuilds,
|
||||
calibrationCacheHits: plan.statistics.calibrationCacheHits,
|
||||
calibrationBytes: plan.calibrationBytes,
|
||||
steeringCacheEnabled: plan.steeringCacheEnabled,
|
||||
steeringCacheBinCapacity: plan.steeringCacheBinCapacity,
|
||||
steeringCachedBinCount: plan.steeringMatrices.size,
|
||||
steeringUncachedBinCount: plan.selectedBins.length - plan.steeringCacheBinCapacity,
|
||||
steeringCacheBytes: plan.steeringCacheBytes,
|
||||
requestedSteeringCacheBytes: plan.requestedSteeringCacheBytes,
|
||||
steeringMatrixBuilds: plan.statistics.steeringMatrixBuilds,
|
||||
steeringCacheHits: plan.statistics.steeringCacheHits,
|
||||
uncachedSteeringUses: plan.statistics.uncachedSteeringUses,
|
||||
steeringPointMicrophonePairs: plan.statistics.steeringPointMicrophonePairs,
|
||||
uncachedSteeringPointMicrophonePairs:
|
||||
plan.statistics.uncachedSteeringPointMicrophonePairs,
|
||||
avoidedSteeringPointMicrophonePairs:
|
||||
plan.statistics.avoidedSteeringPointMicrophonePairs,
|
||||
stftWindowBuilds: plan.statistics.stftWindowBuilds,
|
||||
stftWorkspaceAllocations: plan.statistics.stftWorkspaceAllocations,
|
||||
stftWorkspaceUses: plan.statistics.stftWorkspaceUses,
|
||||
avoidedStftWindowBuilds: plan.statistics.avoidedStftWindowBuilds,
|
||||
avoidedStftWorkspaceAllocations: plan.statistics.avoidedStftWorkspaceAllocations,
|
||||
stftTransforms: plan.statistics.stftTransforms,
|
||||
fftButterflies: plan.statistics.fftButterflies,
|
||||
beamformingComplexMacs: plan.statistics.beamformingComplexMacs,
|
||||
binMapAllocations: plan.statistics.binMapAllocations,
|
||||
steeringWorkspaceAllocations: plan.statistics.steeringWorkspaceAllocations,
|
||||
avoidedBinMapAllocations: plan.statistics.avoidedBinMapAllocations,
|
||||
retainedBytes: plan.statistics.retainedBytes,
|
||||
};
|
||||
}
|
||||
|
||||
export function estimateDasWorkload(options) {
|
||||
const { channels, sampleRate, arrayDefinition, experiment, analysisPlan } = options;
|
||||
const { fftSize, hopSize } = experiment.stft;
|
||||
const bandBins = analysisPlan?.bandBins ??
|
||||
experiment.frequencyBands.map((band) => binsForBand(band, sampleRate, fftSize));
|
||||
const selectedBins = analysisPlan?.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;
|
||||
@@ -99,8 +494,13 @@ export function estimateDasWorkload(options) {
|
||||
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 fftWorkspaceBytes = fftSize * Float64Array.BYTES_PER_ELEMENT * 3;
|
||||
const steeringCacheBytes = analysisPlan?.steeringCacheBytes ?? 0;
|
||||
const calibrationBytes = analysisPlan?.calibrationBytes ??
|
||||
microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2;
|
||||
const baseWorkingBytes =
|
||||
distanceBytes + outputBytes + binMapBytes + fftWorkspaceBytes +
|
||||
steeringCacheBytes + calibrationBytes;
|
||||
const complexMacCount = pointCount * microphoneCount * frameCount * selectedBins.length;
|
||||
return {
|
||||
bandBins,
|
||||
@@ -110,6 +510,10 @@ export function estimateDasWorkload(options) {
|
||||
pointCount,
|
||||
distanceBytes,
|
||||
outputBytes,
|
||||
fftWorkspaceBytes,
|
||||
steeringCacheBytes,
|
||||
calibrationBytes,
|
||||
steeringCacheBinCapacity: analysisPlan?.steeringCacheBinCapacity ?? 0,
|
||||
spectrumBytesPerBin,
|
||||
baseWorkingBytes,
|
||||
complexMacCount,
|
||||
@@ -147,7 +551,7 @@ export function finalizeDasBands(rawMaps, workload, experiment) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function computeDasMap(options) {
|
||||
async function computeDasMapInternal(options, analysisPlan) {
|
||||
const {
|
||||
channels,
|
||||
sampleRate,
|
||||
@@ -167,7 +571,13 @@ export async function computeDasMap(options) {
|
||||
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 });
|
||||
const workload = estimateDasWorkload({
|
||||
channels,
|
||||
sampleRate,
|
||||
arrayDefinition,
|
||||
experiment,
|
||||
analysisPlan,
|
||||
});
|
||||
if (workload.complexMacCount > maximumComplexMacs) {
|
||||
throw new Error(
|
||||
`CPU DAS 예상 복소 누산 ${workload.complexMacCount.toLocaleString()}회가 ` +
|
||||
@@ -199,28 +609,28 @@ export async function computeDasMap(options) {
|
||||
`현재 한도 ${maximumFftButterflies.toLocaleString()}회를 넘습니다. duration, FFT 크기 또는 대역폭을 줄이세요.`,
|
||||
);
|
||||
}
|
||||
const distances = createDistanceMatrix(arrayDefinition, experiment.scan);
|
||||
const distances = analysisPlan?.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);
|
||||
});
|
||||
});
|
||||
const bandIndexesByBin =
|
||||
analysisPlan?.bandIndexesByBin ?? createBandIndexLookup(workload.bandBins);
|
||||
|
||||
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);
|
||||
if (analysisPlan) {
|
||||
analysisPlan.statistics.stftWorkspaceUses += 1;
|
||||
const repeatedUses = Math.max(0, analysisPlan.statistics.stftWorkspaceUses - 1);
|
||||
analysisPlan.statistics.avoidedStftWindowBuilds = repeatedUses;
|
||||
analysisPlan.statistics.avoidedStftWorkspaceAllocations = repeatedUses;
|
||||
}
|
||||
const stft = await extractStftBins(channels, {
|
||||
fftSize: experiment.stft.fftSize,
|
||||
hopSize: experiment.stft.hopSize,
|
||||
bins: batchBins,
|
||||
signal,
|
||||
transformBatchSize: stftTransformBatchSize,
|
||||
workspace: analysisPlan?.stftWorkspace,
|
||||
onProgress: ({ completedTransforms, totalTransforms }) =>
|
||||
onProgress?.({
|
||||
stage: "stft",
|
||||
@@ -232,10 +642,29 @@ export async function computeDasMap(options) {
|
||||
spectrumBatchCount,
|
||||
}),
|
||||
});
|
||||
await yieldToEventLoop();
|
||||
if (analysisPlan) {
|
||||
const completedTransformCount = stft.frameCount * stft.microphoneCount;
|
||||
analysisPlan.statistics.stftTransforms += completedTransformCount;
|
||||
analysisPlan.statistics.fftButterflies +=
|
||||
completedTransformCount *
|
||||
(experiment.stft.fftSize / 2) *
|
||||
Math.log2(experiment.stft.fftSize);
|
||||
}
|
||||
await cooperativeYield();
|
||||
throwIfAborted(signal);
|
||||
|
||||
for (const bin of batchBins) {
|
||||
const calibration = analysisPlan ? planCalibration(analysisPlan, bin) : undefined;
|
||||
const steeringMatrix = analysisPlan
|
||||
? await planSteeringMatrix(analysisPlan, bin, calibration, signal, pointBatchSize)
|
||||
: undefined;
|
||||
if (analysisPlan) {
|
||||
analysisPlan.statistics.binMapUses += 1;
|
||||
analysisPlan.statistics.avoidedBinMapAllocations = Math.max(
|
||||
0,
|
||||
analysisPlan.statistics.binMapUses - 1,
|
||||
);
|
||||
}
|
||||
const binMap = await computeBinMap({
|
||||
bin,
|
||||
spectrum: stft.spectra.get(bin),
|
||||
@@ -246,9 +675,21 @@ export async function computeDasMap(options) {
|
||||
sampleRate,
|
||||
fftSize: experiment.stft.fftSize,
|
||||
speedOfSound: scene.speedOfSound,
|
||||
compensateFrequencyResponse:
|
||||
experiment.beamforming.compensateFrequencyResponse !== false,
|
||||
maxCompensationDb: experiment.beamforming.maxCompensationDb,
|
||||
calibration,
|
||||
steeringMatrix,
|
||||
output: analysisPlan?.binMap,
|
||||
steeringReal: analysisPlan?.steeringReal,
|
||||
steeringImaginary: analysisPlan?.steeringImaginary,
|
||||
signal,
|
||||
pointBatchSize,
|
||||
});
|
||||
if (analysisPlan) {
|
||||
analysisPlan.statistics.beamformingComplexMacs +=
|
||||
workload.pointCount * workload.microphoneCount * stft.frameCount;
|
||||
}
|
||||
for (const bandIndex of bandIndexesByBin.get(bin)) {
|
||||
const rawMap = rawMaps[bandIndex];
|
||||
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
|
||||
@@ -270,5 +711,33 @@ export async function computeDasMap(options) {
|
||||
workload,
|
||||
fftButterflyCount,
|
||||
bands,
|
||||
...(analysisPlan ? { analysisCache: analysisCacheSummary(analysisPlan) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function computeDasMap(options) {
|
||||
const analysisPlan = options.analysisPlan;
|
||||
if (!analysisPlan) return computeDasMapInternal(options, undefined);
|
||||
assertCompatibleAnalysisPlan(analysisPlan, options);
|
||||
if (analysisPlan.busy) {
|
||||
throw new Error("analysisPlan is already in use by another DAS analysis.");
|
||||
}
|
||||
analysisPlan.busy = true;
|
||||
analysisPlan.statistics.analysisAttempts += 1;
|
||||
if (analysisPlan.statistics.analysisAttempts > 1) {
|
||||
analysisPlan.statistics.avoidedDistanceEvaluations +=
|
||||
analysisPlan.pointCount * analysisPlan.microphoneCount;
|
||||
}
|
||||
try {
|
||||
const result = await computeDasMapInternal(options, analysisPlan);
|
||||
analysisPlan.statistics.completedAnalyses += 1;
|
||||
result.analysisCache = {
|
||||
...result.analysisCache,
|
||||
analysisAttempts: analysisPlan.statistics.analysisAttempts,
|
||||
completedAnalyses: analysisPlan.statistics.completedAnalyses,
|
||||
};
|
||||
return result;
|
||||
} finally {
|
||||
analysisPlan.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { dbToLinear, distance3 } from "./math.js";
|
||||
import { hashString, whiteNoiseAt } from "./noise.js";
|
||||
|
||||
const WALLS = [
|
||||
{ id: "xMin", axis: 0, boundary: "min" },
|
||||
{ id: "xMax", axis: 0, boundary: "max" },
|
||||
{ id: "yMin", axis: 1, boundary: "min" },
|
||||
{ id: "yMax", axis: 1, boundary: "max" },
|
||||
{ id: "zMin", axis: 2, boundary: "min" },
|
||||
{ id: "zMax", axis: 2, boundary: "max" },
|
||||
];
|
||||
|
||||
const GEOMETRY_EPSILON = 1e-9;
|
||||
|
||||
function insideBounds(position, bounds, epsilon = GEOMETRY_EPSILON) {
|
||||
return position.every(
|
||||
(coordinate, axis) =>
|
||||
coordinate >= bounds.min[axis] - epsilon && coordinate <= bounds.max[axis] + epsilon,
|
||||
);
|
||||
}
|
||||
|
||||
function segmentIntersectsBox(start, end, box) {
|
||||
let entry = 0;
|
||||
let exit = 1;
|
||||
for (let axis = 0; axis < 3; axis += 1) {
|
||||
const delta = end[axis] - start[axis];
|
||||
if (Math.abs(delta) <= GEOMETRY_EPSILON) {
|
||||
if (start[axis] < box.min[axis] || start[axis] > box.max[axis]) return false;
|
||||
continue;
|
||||
}
|
||||
const first = (box.min[axis] - start[axis]) / delta;
|
||||
const second = (box.max[axis] - start[axis]) / delta;
|
||||
entry = Math.max(entry, Math.min(first, second));
|
||||
exit = Math.min(exit, Math.max(first, second));
|
||||
if (entry > exit) return false;
|
||||
}
|
||||
return exit > GEOMETRY_EPSILON && entry < 1 - GEOMETRY_EPSILON;
|
||||
}
|
||||
|
||||
function shortestCornerDetour(start, end, box) {
|
||||
let shortestDistance = Infinity;
|
||||
for (let mask = 0; mask < 8; mask += 1) {
|
||||
const corner = [0, 1, 2].map((axis) =>
|
||||
mask & (1 << axis) ? box.max[axis] : box.min[axis],
|
||||
);
|
||||
shortestDistance = Math.min(
|
||||
shortestDistance,
|
||||
distance3(start, corner) + distance3(corner, end),
|
||||
);
|
||||
}
|
||||
return shortestDistance;
|
||||
}
|
||||
|
||||
function imageSourceDistance(source, receiver, room, wall) {
|
||||
if (!insideBounds(source, room) || !insideBounds(receiver, room)) return null;
|
||||
const plane = room[wall.boundary][wall.axis];
|
||||
const image = [...source];
|
||||
image[wall.axis] = 2 * plane - source[wall.axis];
|
||||
const denominator = receiver[wall.axis] - image[wall.axis];
|
||||
if (Math.abs(denominator) <= GEOMETRY_EPSILON) return null;
|
||||
const amount = (plane - image[wall.axis]) / denominator;
|
||||
if (amount <= GEOMETRY_EPSILON || amount >= 1 - GEOMETRY_EPSILON) return null;
|
||||
const reflectionPoint = image.map(
|
||||
(coordinate, axis) => coordinate + (receiver[axis] - coordinate) * amount,
|
||||
);
|
||||
for (let axis = 0; axis < 3; axis += 1) {
|
||||
if (axis === wall.axis) continue;
|
||||
if (
|
||||
reflectionPoint[axis] < room.min[axis] - GEOMETRY_EPSILON ||
|
||||
reflectionPoint[axis] > room.max[axis] + GEOMETRY_EPSILON
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return distance3(image, receiver);
|
||||
}
|
||||
|
||||
function pathGain(distance, coefficient, referenceDistance, minimumDistance) {
|
||||
return coefficient * referenceDistance / Math.max(distance, minimumDistance);
|
||||
}
|
||||
|
||||
function deterministicUnit(seed, index) {
|
||||
return (whiteNoiseAt(seed, index) + 1) / 2;
|
||||
}
|
||||
|
||||
function stableSeed(scene, sourceId, receiverId) {
|
||||
return (
|
||||
(scene.seed ?? 0) ^
|
||||
hashString(String(sourceId ?? "source")) ^
|
||||
Math.imul(hashString(String(receiverId ?? "receiver")), 0x45d9f3b)
|
||||
) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a sparse, deterministic set of propagation paths for one source/receiver pair.
|
||||
*
|
||||
* The optional shoebox model is intentionally bounded: direct sound, six first-order
|
||||
* image sources, at most one edge-detour per occluder, and a capped diffuse tail.
|
||||
* It is useful for array/algorithm comparisons; it is not a room-acoustics solver.
|
||||
*/
|
||||
export function buildAcousticPaths(options) {
|
||||
const {
|
||||
scene,
|
||||
sourcePosition,
|
||||
receiverPosition,
|
||||
sourceId,
|
||||
receiverId,
|
||||
referenceDistance = 1,
|
||||
minimumDistance = 0.01,
|
||||
} = options;
|
||||
const speedOfSound = scene.speedOfSound;
|
||||
const directDistance = distance3(sourcePosition, receiverPosition);
|
||||
const acoustics = scene.acoustics;
|
||||
if (!acoustics || acoustics.model === "free-field") {
|
||||
return [{
|
||||
kind: "direct",
|
||||
distance: directDistance,
|
||||
delaySeconds: directDistance / speedOfSound,
|
||||
gain: pathGain(directDistance, 1, referenceDistance, minimumDistance),
|
||||
}];
|
||||
}
|
||||
|
||||
const paths = [];
|
||||
const blockers = (acoustics.occluders ?? []).filter((occluder) =>
|
||||
segmentIntersectsBox(sourcePosition, receiverPosition, occluder),
|
||||
);
|
||||
const transmission = blockers.reduce(
|
||||
(coefficient, occluder) => coefficient * dbToLinear(occluder.transmissionDb),
|
||||
1,
|
||||
);
|
||||
paths.push({
|
||||
kind: "direct",
|
||||
distance: directDistance,
|
||||
delaySeconds: directDistance / speedOfSound,
|
||||
gain: pathGain(directDistance, transmission, referenceDistance, minimumDistance),
|
||||
blockedBy: blockers.map(({ id }) => id),
|
||||
});
|
||||
|
||||
for (const occluder of blockers) {
|
||||
const distance = shortestCornerDetour(sourcePosition, receiverPosition, occluder);
|
||||
paths.push({
|
||||
kind: "diffraction",
|
||||
occluderId: occluder.id,
|
||||
distance,
|
||||
delaySeconds: distance / speedOfSound,
|
||||
gain: pathGain(
|
||||
distance,
|
||||
dbToLinear(occluder.diffractionGainDb),
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const reflections = acoustics.reflections;
|
||||
const seed = stableSeed(scene, sourceId, receiverId);
|
||||
if (reflections?.order === 1 && acoustics.room) {
|
||||
const scattering = reflections.scattering ?? 0;
|
||||
for (let wallIndex = 0; wallIndex < WALLS.length; wallIndex += 1) {
|
||||
const wall = WALLS[wallIndex];
|
||||
const distance = imageSourceDistance(
|
||||
sourcePosition,
|
||||
receiverPosition,
|
||||
acoustics.room,
|
||||
wall,
|
||||
);
|
||||
if (distance === null) continue;
|
||||
const wallDb = reflections.perWallDb?.[wall.id] ?? reflections.wallGainDb;
|
||||
const reflectionCoefficient = dbToLinear(wallDb);
|
||||
const specularCoefficient = reflectionCoefficient * Math.sqrt(1 - scattering);
|
||||
if (specularCoefficient > 0) {
|
||||
paths.push({
|
||||
kind: "reflection",
|
||||
surface: wall.id,
|
||||
distance,
|
||||
delaySeconds: distance / speedOfSound,
|
||||
gain: pathGain(distance, specularCoefficient, referenceDistance, minimumDistance),
|
||||
});
|
||||
}
|
||||
if (scattering > 0) {
|
||||
const jitterSeconds = 0.0002 + deterministicUnit(seed, wallIndex) * 0.0018;
|
||||
const sign = whiteNoiseAt(seed ^ 0x9e3779b9, wallIndex) < 0 ? -1 : 1;
|
||||
paths.push({
|
||||
kind: "scattering",
|
||||
surface: wall.id,
|
||||
distance: distance + jitterSeconds * speedOfSound,
|
||||
delaySeconds: distance / speedOfSound + jitterSeconds,
|
||||
gain: pathGain(
|
||||
distance,
|
||||
reflectionCoefficient * Math.sqrt(scattering) * sign,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const diffuse = acoustics.diffuseReverb;
|
||||
if (diffuse?.enabled) {
|
||||
const tapGain = dbToLinear(diffuse.gainDb) / Math.sqrt(diffuse.tapCount);
|
||||
for (let tapIndex = 0; tapIndex < diffuse.tapCount; tapIndex += 1) {
|
||||
const progress = (tapIndex + 1) / (diffuse.tapCount + 1);
|
||||
const tailTime = progress * diffuse.rt60Seconds;
|
||||
const jitterLimit = Math.min(0.001, diffuse.rt60Seconds / (diffuse.tapCount * 4));
|
||||
const jitter = deterministicUnit(seed ^ 0xa511e9b3, tapIndex) * jitterLimit;
|
||||
const decay = 10 ** ((-60 * tailTime / diffuse.rt60Seconds) / 20);
|
||||
const sign = whiteNoiseAt(seed ^ 0x63d83595, tapIndex) < 0 ? -1 : 1;
|
||||
paths.push({
|
||||
kind: "diffuse",
|
||||
distance: directDistance + (tailTime + jitter) * speedOfSound,
|
||||
delaySeconds: directDistance / speedOfSound + tailTime + jitter,
|
||||
gain: pathGain(
|
||||
directDistance,
|
||||
tapGain * decay * sign,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function maximumAcousticDelaySeconds(options) {
|
||||
const { scene, arrayDefinition, referenceDistance, minimumDistance } = options;
|
||||
let maximum = 0;
|
||||
for (const source of scene.sources) {
|
||||
for (const microphone of arrayDefinition.microphones) {
|
||||
const paths = buildAcousticPaths({
|
||||
scene,
|
||||
sourcePosition: source.position,
|
||||
receiverPosition: microphone.position,
|
||||
sourceId: source.id,
|
||||
receiverId: microphone.id,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
});
|
||||
for (const path of paths) maximum = Math.max(maximum, path.delaySeconds);
|
||||
}
|
||||
}
|
||||
return maximum;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fftInPlace } from "./fft.js";
|
||||
import { interpolateFrequencyResponse } from "./frequency-response.js";
|
||||
import { TAU } from "./math.js";
|
||||
|
||||
export function nextPowerOfTwo(value) {
|
||||
@@ -21,25 +22,96 @@ 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}`);
|
||||
export function addFrequencyDomainPaths(options) {
|
||||
const {
|
||||
output,
|
||||
spectrum,
|
||||
paths,
|
||||
gain = 1,
|
||||
phaseOffsetDeg = 0,
|
||||
frequencyResponse,
|
||||
sampleRate,
|
||||
workspace,
|
||||
} = options;
|
||||
if (!Array.isArray(paths) || paths.length === 0) {
|
||||
throw new Error("frequency-domain path가 하나 이상 필요합니다.");
|
||||
}
|
||||
for (const path of paths) {
|
||||
if (!(path.delaySamples >= 0) || !Number.isFinite(path.delaySamples)) {
|
||||
throw new Error("유효하지 않은 delay: " + path.delaySamples);
|
||||
}
|
||||
if (!Number.isFinite(path.gain)) throw new Error("path gain은 유한한 숫자여야 합니다.");
|
||||
}
|
||||
if (workspace.real.length !== spectrum.fftSize || workspace.imaginary.length !== spectrum.fftSize) {
|
||||
throw new Error("fractional-delay workspace 크기가 spectrum과 다릅니다.");
|
||||
}
|
||||
const hasFrequencyResponse = Array.isArray(frequencyResponse) && frequencyResponse.length > 0;
|
||||
if (hasFrequencyResponse && (!(sampleRate > 0) || !Number.isFinite(sampleRate))) {
|
||||
throw new Error("frequencyResponse 적용에는 유효한 sampleRate가 필요합니다.");
|
||||
}
|
||||
|
||||
const size = spectrum.fftSize;
|
||||
const calibrationPhase = (phaseOffsetDeg * Math.PI) / 180;
|
||||
const response = {};
|
||||
workspace.real.fill(0);
|
||||
workspace.imaginary.fill(0);
|
||||
const halfSize = size / 2;
|
||||
const phaseReanchorInterval = 1024;
|
||||
for (const path of paths) {
|
||||
const phaseStep = (-TAU * path.delaySamples) / size;
|
||||
const stepReal = Math.cos(phaseStep);
|
||||
const stepImaginary = Math.sin(phaseStep);
|
||||
let phaseReal = 1;
|
||||
let phaseImaginary = 0;
|
||||
for (let bin = 0; bin <= halfSize; bin += 1) {
|
||||
workspace.real[bin] += phaseReal * path.gain;
|
||||
workspace.imaginary[bin] += phaseImaginary * path.gain;
|
||||
if (bin > 0 && bin < halfSize) {
|
||||
const negativeBin = size - bin;
|
||||
workspace.real[negativeBin] += phaseReal * path.gain;
|
||||
workspace.imaginary[negativeBin] -= phaseImaginary * path.gain;
|
||||
}
|
||||
const nextBin = bin + 1;
|
||||
if (nextBin % phaseReanchorInterval === 0) {
|
||||
phaseReal = Math.cos(phaseStep * nextBin);
|
||||
phaseImaginary = Math.sin(phaseStep * nextBin);
|
||||
} else {
|
||||
const nextReal = phaseReal * stepReal - phaseImaginary * stepImaginary;
|
||||
phaseImaginary = phaseReal * stepImaginary + phaseImaginary * stepReal;
|
||||
phaseReal = nextReal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
let responseGain = 1;
|
||||
let responsePhase = 0;
|
||||
if (hasFrequencyResponse) {
|
||||
interpolateFrequencyResponse(
|
||||
frequencyResponse,
|
||||
(Math.abs(signedBin) * sampleRate) / size,
|
||||
response,
|
||||
);
|
||||
responseGain = response.linearGain;
|
||||
responsePhase = response.phaseRadians;
|
||||
}
|
||||
const pathReal = workspace.real[bin];
|
||||
const pathImaginary = workspace.imaginary[bin];
|
||||
const responseAndCalibrationPhase = phaseSign * (calibrationPhase + responsePhase);
|
||||
const calibrationReal = Math.cos(responseAndCalibrationPhase) * responseGain * gain;
|
||||
const calibrationImaginary = Math.sin(responseAndCalibrationPhase) * responseGain * gain;
|
||||
const transferReal =
|
||||
pathReal * calibrationReal - pathImaginary * calibrationImaginary;
|
||||
const transferImaginary =
|
||||
pathReal * calibrationImaginary + pathImaginary * calibrationReal;
|
||||
const inputReal = spectrum.real[bin];
|
||||
const inputImaginary = spectrum.imaginary[bin];
|
||||
workspace.real[bin] = inputReal * phaseReal - inputImaginary * phaseImaginary;
|
||||
workspace.imaginary[bin] = inputReal * phaseImaginary + inputImaginary * phaseReal;
|
||||
workspace.real[bin] =
|
||||
inputReal * transferReal - inputImaginary * transferImaginary;
|
||||
workspace.imaginary[bin] =
|
||||
inputReal * transferImaginary + inputImaginary * transferReal;
|
||||
}
|
||||
|
||||
// A real sequence has a real-valued Nyquist coefficient. Projecting this single self-conjugate
|
||||
@@ -48,6 +120,14 @@ export function addFrequencyDomainDelay(options) {
|
||||
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;
|
||||
output[sampleIndex] += workspace.real[sampleIndex];
|
||||
}
|
||||
}
|
||||
|
||||
export function addFrequencyDomainDelay(options) {
|
||||
const { delaySamples, gain = 1, ...shared } = options;
|
||||
return addFrequencyDomainPaths({
|
||||
...shared,
|
||||
paths: [{ delaySamples, gain }],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { dbToLinear } from "./math.js";
|
||||
|
||||
export const MAX_FREQUENCY_RESPONSE_POINTS = 64;
|
||||
export const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000;
|
||||
export const DEFAULT_MAX_COMPENSATION_DB = 24;
|
||||
export const MAX_COMPENSATION_DB = 120;
|
||||
|
||||
export function requireMaxCompensationDb(
|
||||
value = DEFAULT_MAX_COMPENSATION_DB,
|
||||
label = "maxCompensationDb",
|
||||
) {
|
||||
if (!Number.isFinite(value) || value < 0 || value > MAX_COMPENSATION_DB) {
|
||||
throw new RangeError(`${label} must lie inside 0..${MAX_COMPENSATION_DB} dB.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the inverse response gain in dB while limiting only positive boost.
|
||||
* Attenuation remains exact because it cannot amplify a response notch or the
|
||||
* microphone self-noise that occupies it.
|
||||
*/
|
||||
export function inverseResponseGainDb(responseGainDb, maxCompensationDb) {
|
||||
if (!Number.isFinite(responseGainDb)) {
|
||||
throw new RangeError("responseGainDb must be finite.");
|
||||
}
|
||||
return Math.min(
|
||||
-responseGainDb,
|
||||
requireMaxCompensationDb(maxCompensationDb),
|
||||
);
|
||||
}
|
||||
|
||||
function phaseAt(point) {
|
||||
return point.phaseDeg ?? 0;
|
||||
}
|
||||
|
||||
function wrapPhaseDeg(value) {
|
||||
const wrapped = ((value + 180) % 360 + 360) % 360 - 180;
|
||||
return Object.is(wrapped, -0) ? 0 : wrapped;
|
||||
}
|
||||
|
||||
function writeResponse(output, gainDb, phaseDeg) {
|
||||
output.gainDb = gainDb;
|
||||
output.phaseDeg = wrapPhaseDeg(phaseDeg);
|
||||
output.linearGain = dbToLinear(gainDb);
|
||||
output.phaseRadians = (output.phaseDeg * Math.PI) / 180;
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Piecewise-linear microphone response interpolation.
|
||||
*
|
||||
* Frequencies outside the measured curve use the nearest endpoint. Phase follows
|
||||
* the shortest wrapped path between points, avoiding a discontinuity at ±180°.
|
||||
* Passing an output object lets FFT loops reuse storage and avoid per-bin garbage.
|
||||
*/
|
||||
export function interpolateFrequencyResponse(points, frequencyHz, output = {}) {
|
||||
if (!Number.isFinite(frequencyHz) || frequencyHz < 0) {
|
||||
throw new RangeError(`frequencyHz must be a finite non-negative number: ${frequencyHz}`);
|
||||
}
|
||||
if (points === undefined || points === null || points.length === 0) {
|
||||
return writeResponse(output, 0, 0);
|
||||
}
|
||||
if (!Array.isArray(points)) throw new TypeError("frequency response points must be an array.");
|
||||
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
if (frequencyHz <= first.frequencyHz) {
|
||||
return writeResponse(output, first.gainDb, phaseAt(first));
|
||||
}
|
||||
if (frequencyHz >= last.frequencyHz) {
|
||||
return writeResponse(output, last.gainDb, phaseAt(last));
|
||||
}
|
||||
|
||||
let low = 0;
|
||||
let high = points.length - 1;
|
||||
while (high - low > 1) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if (frequencyHz < points[middle].frequencyHz) high = middle;
|
||||
else low = middle;
|
||||
}
|
||||
const left = points[low];
|
||||
const right = points[high];
|
||||
const ratio = (frequencyHz - left.frequencyHz) / (right.frequencyHz - left.frequencyHz);
|
||||
const gainDb = left.gainDb + (right.gainDb - left.gainDb) * ratio;
|
||||
const leftPhase = phaseAt(left);
|
||||
const phaseDelta = wrapPhaseDeg(phaseAt(right) - leftPhase);
|
||||
return writeResponse(output, gainDb, leftPhase + phaseDelta * ratio);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
export * from "./das.js";
|
||||
export * from "./evaluation.js";
|
||||
export * from "./environment.js";
|
||||
export * from "./fft.js";
|
||||
export * from "./frequency-response.js";
|
||||
export * from "./fractional-delay.js";
|
||||
export * from "./math.js";
|
||||
export * from "./mixdown.js";
|
||||
export * from "./noise.js";
|
||||
export * from "./propagation.js";
|
||||
export * from "./realtime-localizer.js";
|
||||
export * from "./signal.js";
|
||||
export * from "./stft.js";
|
||||
export * from "./trajectory.js";
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { dbToLinear, distance3 } from "./math.js";
|
||||
import {
|
||||
addFrequencyDomainDelay,
|
||||
addFrequencyDomainPaths,
|
||||
createDelayWorkspace,
|
||||
nextPowerOfTwo,
|
||||
prepareDelaySpectrum,
|
||||
} from "./fractional-delay.js";
|
||||
import { buildAcousticPaths, maximumAcousticDelaySeconds } from "./environment.js";
|
||||
import { renderSignal } from "./signal.js";
|
||||
|
||||
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
|
||||
@@ -27,15 +29,13 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
} = 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 maximumDelaySamples =
|
||||
maximumAcousticDelaySeconds({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
}) * scene.sampleRate;
|
||||
const delayFftSize = nextPowerOfTwo(sampleCount + Math.ceil(maximumDelaySamples) + 2);
|
||||
const delayWorkingBytes = delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4;
|
||||
const estimatedBufferBytes =
|
||||
@@ -56,22 +56,49 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
}),
|
||||
}));
|
||||
|
||||
const acousticPathCounts = {};
|
||||
let acousticPathCount = 0;
|
||||
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 microphonePaths = arrayDefinition.microphones.map((microphone) =>
|
||||
buildAcousticPaths({
|
||||
scene,
|
||||
sourcePosition: definition.position,
|
||||
receiverPosition: microphone.position,
|
||||
sourceId: definition.id,
|
||||
receiverId: microphone.id,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
}),
|
||||
);
|
||||
let sourceMaximumDelaySamples = 0;
|
||||
for (const paths of microphonePaths) {
|
||||
for (const path of paths) {
|
||||
sourceMaximumDelaySamples = Math.max(
|
||||
sourceMaximumDelaySamples,
|
||||
path.delaySeconds * scene.sampleRate,
|
||||
);
|
||||
}
|
||||
}
|
||||
const spectrum = prepareDelaySpectrum(samples, sourceMaximumDelaySamples);
|
||||
const workspace = createDelayWorkspace(spectrum.fftSize);
|
||||
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
|
||||
const distance = distances[microphoneIndex];
|
||||
const delaySamples = (distance / scene.speedOfSound) * scene.sampleRate;
|
||||
const paths = microphonePaths[microphoneIndex];
|
||||
for (const path of paths) {
|
||||
acousticPathCount += 1;
|
||||
acousticPathCounts[path.kind] = (acousticPathCounts[path.kind] ?? 0) + 1;
|
||||
}
|
||||
const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0);
|
||||
const amplitude = sphericalAttenuation(distance, referenceDistance, minimumDistance) * sensitivity;
|
||||
addFrequencyDomainDelay({
|
||||
addFrequencyDomainPaths({
|
||||
output: channels[microphoneIndex],
|
||||
spectrum,
|
||||
delaySamples,
|
||||
gain: amplitude,
|
||||
paths: paths.map((path) => ({
|
||||
delaySamples: path.delaySeconds * scene.sampleRate,
|
||||
gain: path.gain,
|
||||
})),
|
||||
gain: sensitivity,
|
||||
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
||||
frequencyResponse: microphone.frequencyResponse,
|
||||
sampleRate: scene.sampleRate,
|
||||
workspace,
|
||||
});
|
||||
});
|
||||
@@ -93,6 +120,8 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
delaySamples: 0,
|
||||
gain: dbToLinear(microphone.sensitivityDb ?? 0),
|
||||
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
||||
frequencyResponse: microphone.frequencyResponse,
|
||||
sampleRate: scene.sampleRate,
|
||||
workspace: createDelayWorkspace(spectrum.fftSize),
|
||||
});
|
||||
});
|
||||
@@ -127,5 +156,8 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
appliedScale,
|
||||
clippedSampleCount,
|
||||
estimatedBufferBytes,
|
||||
acousticModel: scene.acoustics?.model ?? "free-field",
|
||||
acousticPathCount,
|
||||
acousticPathCounts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
import { computeDasMap, createDasAnalysisPlan } from "./das.js";
|
||||
import { findLocalPeaks } from "./evaluation.js";
|
||||
import {
|
||||
DEFAULT_MAX_COMPENSATION_DB,
|
||||
MAX_FREQUENCY_RESPONSE_HZ,
|
||||
MAX_FREQUENCY_RESPONSE_POINTS,
|
||||
requireMaxCompensationDb,
|
||||
} from "./frequency-response.js";
|
||||
|
||||
export const REALTIME_LOCALIZATION_PLAN_KIND = "micarrsim.realtime-localization-plan/1";
|
||||
export const DEFAULT_STREAM_RESULTS_PER_CALL = 1;
|
||||
export const MAX_STREAM_RESULTS_PER_CALL = 32;
|
||||
export const DEFAULT_MAXIMUM_STREAM_INPUT_BYTES = 256 * 1024 * 1024;
|
||||
export const DEFAULT_REALTIME_POINT_BATCH_SIZE = 256;
|
||||
export const DEFAULT_REALTIME_STFT_TRANSFORM_BATCH_SIZE = 128;
|
||||
|
||||
function requirePositiveFinite(value, label) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new RangeError(`${label} must be a positive finite number.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePositiveInteger(value, label) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new RangeError(`${label} must be a positive integer.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePowerOfTwo(value, label) {
|
||||
requirePositiveInteger(value, label);
|
||||
if (value < 2 || (value & (value - 1)) !== 0) {
|
||||
throw new RangeError(`${label} must be a power of two greater than one.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function monotonicNow() {
|
||||
return globalThis.performance?.now?.() ?? Date.now();
|
||||
}
|
||||
|
||||
function freezePosition(position, label) {
|
||||
if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) {
|
||||
throw new TypeError(`${label} must be a finite [x, y, z] array.`);
|
||||
}
|
||||
return Object.freeze([...position]);
|
||||
}
|
||||
|
||||
function copyFrequencyResponse(value, label) {
|
||||
if (value === undefined) return undefined;
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length < 2 ||
|
||||
value.length > MAX_FREQUENCY_RESPONSE_POINTS
|
||||
) {
|
||||
throw new RangeError(
|
||||
label + " must contain 2-" + MAX_FREQUENCY_RESPONSE_POINTS + " points.",
|
||||
);
|
||||
}
|
||||
let previousFrequency = -Infinity;
|
||||
return Object.freeze(value.map((point, index) => {
|
||||
const pointLabel = label + "[" + index + "]";
|
||||
if (!point || typeof point !== "object") {
|
||||
throw new TypeError(pointLabel + " must be an object.");
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(point.frequencyHz) ||
|
||||
point.frequencyHz < 0 ||
|
||||
point.frequencyHz > MAX_FREQUENCY_RESPONSE_HZ ||
|
||||
point.frequencyHz <= previousFrequency
|
||||
) {
|
||||
throw new RangeError(pointLabel + ".frequencyHz must be finite and strictly increasing.");
|
||||
}
|
||||
if (!Number.isFinite(point.gainDb) || point.gainDb < -120 || point.gainDb > 120) {
|
||||
throw new RangeError(pointLabel + ".gainDb must lie inside -120..120 dB.");
|
||||
}
|
||||
if (
|
||||
point.phaseDeg !== undefined &&
|
||||
(!Number.isFinite(point.phaseDeg) || point.phaseDeg < -180 || point.phaseDeg > 180)
|
||||
) {
|
||||
throw new RangeError(pointLabel + ".phaseDeg must lie inside -180..180 degrees.");
|
||||
}
|
||||
previousFrequency = point.frequencyHz;
|
||||
return Object.freeze({ ...point });
|
||||
}));
|
||||
}
|
||||
|
||||
function copyArrayDefinition(value) {
|
||||
if (!value || !Array.isArray(value.microphones) || value.microphones.length === 0) {
|
||||
throw new TypeError("array.microphones must be a non-empty array.");
|
||||
}
|
||||
const microphoneIds = new Set();
|
||||
const microphones = value.microphones.map((microphone, index) => {
|
||||
const id = microphone.id ?? "mic-" + index;
|
||||
if (microphoneIds.has(id)) throw new Error("duplicate microphone id: " + id);
|
||||
microphoneIds.add(id);
|
||||
if (
|
||||
microphone.sensitivityDb !== undefined &&
|
||||
!Number.isFinite(microphone.sensitivityDb)
|
||||
) {
|
||||
throw new TypeError("array.microphones[" + index + "].sensitivityDb must be finite.");
|
||||
}
|
||||
if (
|
||||
microphone.phaseOffsetDeg !== undefined &&
|
||||
!Number.isFinite(microphone.phaseOffsetDeg)
|
||||
) {
|
||||
throw new TypeError("array.microphones[" + index + "].phaseOffsetDeg must be finite.");
|
||||
}
|
||||
const frequencyResponse = copyFrequencyResponse(
|
||||
microphone.frequencyResponse,
|
||||
"array.microphones[" + index + "].frequencyResponse",
|
||||
);
|
||||
return Object.freeze({
|
||||
...microphone,
|
||||
id,
|
||||
position: freezePosition(
|
||||
microphone.position,
|
||||
"array.microphones[" + index + "].position",
|
||||
),
|
||||
...(frequencyResponse ? { frequencyResponse: Object.freeze(frequencyResponse) } : {}),
|
||||
});
|
||||
});
|
||||
return Object.freeze({
|
||||
...value,
|
||||
microphones: Object.freeze(microphones),
|
||||
});
|
||||
}
|
||||
|
||||
function copyScan(scan) {
|
||||
if (!scan || typeof scan !== "object") throw new TypeError("scan must be an object.");
|
||||
const numericFields = ["xMin", "xMax", "yMin", "yMax", "z"];
|
||||
for (const field of numericFields) {
|
||||
if (!Number.isFinite(scan[field])) throw new TypeError(`scan.${field} must be finite.`);
|
||||
}
|
||||
const columns = requirePositiveInteger(scan.columns, "scan.columns");
|
||||
const rows = requirePositiveInteger(scan.rows, "scan.rows");
|
||||
if (scan.xMax < scan.xMin || scan.yMax < scan.yMin) {
|
||||
throw new RangeError("scan bounds must satisfy min <= max.");
|
||||
}
|
||||
return Object.freeze({ ...scan, columns, rows });
|
||||
}
|
||||
|
||||
function copyBands(bands, sampleRate) {
|
||||
if (!Array.isArray(bands) || bands.length === 0) {
|
||||
throw new TypeError("bands must be a non-empty array.");
|
||||
}
|
||||
const nyquist = sampleRate / 2;
|
||||
return Object.freeze(bands.map((band, index) => {
|
||||
if (
|
||||
!Number.isFinite(band?.minHz) ||
|
||||
!Number.isFinite(band?.maxHz) ||
|
||||
band.minHz < 0 ||
|
||||
band.maxHz <= band.minHz ||
|
||||
band.maxHz >= nyquist
|
||||
) {
|
||||
throw new RangeError(`bands[${index}] must lie inside 0–${nyquist} Hz.`);
|
||||
}
|
||||
return Object.freeze({
|
||||
...band,
|
||||
id: band.id ?? `band-${index + 1}`,
|
||||
color: band.color ?? "#65f2bf",
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
function copyPeakDetection(value, scan) {
|
||||
const options = value ?? {};
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
||||
throw new TypeError("peakDetection must be an object.");
|
||||
}
|
||||
const thresholdRatio = options.thresholdRatio ?? 0.5;
|
||||
if (!Number.isFinite(thresholdRatio) || thresholdRatio < 0 || thresholdRatio > 1) {
|
||||
throw new RangeError("peakDetection.thresholdRatio must lie inside 0..1.");
|
||||
}
|
||||
const xStep = scan.columns > 1 ? (scan.xMax - scan.xMin) / (scan.columns - 1) : 0;
|
||||
const yStep = scan.rows > 1 ? (scan.yMax - scan.yMin) / (scan.rows - 1) : 0;
|
||||
const minimumPeakDistanceM = options.minimumPeakDistanceM ?? Math.hypot(xStep, yStep);
|
||||
if (!Number.isFinite(minimumPeakDistanceM) || minimumPeakDistanceM < 0) {
|
||||
throw new RangeError("peakDetection.minimumPeakDistanceM must be a non-negative finite number.");
|
||||
}
|
||||
const maximumDetections = options.maximumDetections ?? 8;
|
||||
if (!Number.isInteger(maximumDetections) || maximumDetections < 1 || maximumDetections > 128) {
|
||||
throw new RangeError("peakDetection.maximumDetections must be an integer from 1 through 128.");
|
||||
}
|
||||
return Object.freeze({ thresholdRatio, minimumPeakDistanceM, maximumDetections });
|
||||
}
|
||||
|
||||
function validateChannels(channels, expectedChannelCount) {
|
||||
if (!Array.isArray(channels) || channels.length !== expectedChannelCount) {
|
||||
throw new Error(
|
||||
`channel count ${channels?.length ?? 0} does not match microphone count ${expectedChannelCount}.`,
|
||||
);
|
||||
}
|
||||
if (!channels.every((channel) => channel instanceof Float32Array)) {
|
||||
throw new TypeError("every channel must be a Float32Array.");
|
||||
}
|
||||
const sampleCount = channels[0].length;
|
||||
if (channels.some((channel) => channel.length !== sampleCount)) {
|
||||
throw new Error("all channels must have the same sample count.");
|
||||
}
|
||||
return sampleCount;
|
||||
}
|
||||
|
||||
function createAbortError(signal) {
|
||||
if (signal?.reason instanceof Error && signal.reason.name === "AbortError") return signal.reason;
|
||||
const error = new Error("localization was canceled.");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal) {
|
||||
if (signal?.aborted) throw createAbortError(signal);
|
||||
}
|
||||
|
||||
export function detectBandPeaks(rawMap, scan, options = {}) {
|
||||
const peakDetection = copyPeakDetection(options, scan);
|
||||
const localPeaks = findLocalPeaks(rawMap, scan);
|
||||
const threshold = (localPeaks[0]?.value ?? 0) * peakDetection.thresholdRatio;
|
||||
const detections = [];
|
||||
for (const peak of localPeaks) {
|
||||
if (peak.value < threshold) continue;
|
||||
if (
|
||||
peakDetection.minimumPeakDistanceM > 0 &&
|
||||
detections.some((selected) =>
|
||||
Math.hypot(
|
||||
selected.position[0] - peak.position[0],
|
||||
selected.position[1] - peak.position[1],
|
||||
) <= peakDetection.minimumPeakDistanceM
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
detections.push({
|
||||
rank: detections.length + 1,
|
||||
index: peak.index,
|
||||
position: [...peak.position],
|
||||
rawPower: peak.value,
|
||||
});
|
||||
if (detections.length === peakDetection.maximumDetections) break;
|
||||
}
|
||||
return detections;
|
||||
}
|
||||
|
||||
function locationsFromBands(bands, scan, peakDetection) {
|
||||
return bands.flatMap((band) =>
|
||||
detectBandPeaks(band.rawMap, scan, peakDetection).map((peak) => ({
|
||||
bandId: band.id,
|
||||
...peak,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile immutable microphone/scan/band configuration and CPU geometry caches.
|
||||
*
|
||||
* This API deliberately has no Scene or synthesis dependency: callers provide
|
||||
* sampled microphone channels directly to localizeMultichannel().
|
||||
*/
|
||||
export function createLocalizationPlan(config) {
|
||||
if (!config || typeof config !== "object") throw new TypeError("config must be an object.");
|
||||
const sampleRate = requirePositiveFinite(config.sampleRate, "sampleRate");
|
||||
const arrayDefinition = copyArrayDefinition(config.array ?? config.arrayDefinition);
|
||||
const scan = copyScan(config.scan);
|
||||
const peakDetection = copyPeakDetection(config.peakDetection, scan);
|
||||
const frequencyBands = copyBands(config.bands ?? config.frequencyBands, sampleRate);
|
||||
const fftSize = requirePowerOfTwo(config.stft?.fftSize ?? config.fftSize ?? 512, "fftSize");
|
||||
const hopSize = requirePositiveInteger(
|
||||
config.stft?.hopSize ?? config.stftHopSize ?? fftSize / 2,
|
||||
"stftHopSize",
|
||||
);
|
||||
if (hopSize > fftSize) throw new RangeError("stftHopSize must not exceed fftSize.");
|
||||
const speedOfSound = requirePositiveFinite(config.speedOfSound ?? 343, "speedOfSound");
|
||||
const normalization = config.normalization ?? config.beamforming?.normalization ?? "peak";
|
||||
if (normalization !== "peak" && normalization !== "none") {
|
||||
throw new RangeError('normalization must be "peak" or "none".');
|
||||
}
|
||||
const compensateFrequencyResponse =
|
||||
config.compensateFrequencyResponse ??
|
||||
config.beamforming?.compensateFrequencyResponse ??
|
||||
true;
|
||||
if (typeof compensateFrequencyResponse !== "boolean") {
|
||||
throw new TypeError("compensateFrequencyResponse must be boolean.");
|
||||
}
|
||||
const maxCompensationDb = requireMaxCompensationDb(
|
||||
config.maxCompensationDb ??
|
||||
config.beamforming?.maxCompensationDb ??
|
||||
DEFAULT_MAX_COMPENSATION_DB,
|
||||
);
|
||||
|
||||
const scene = Object.freeze({ speedOfSound });
|
||||
const experiment = Object.freeze({
|
||||
stft: Object.freeze({ fftSize, hopSize, window: "hann" }),
|
||||
scan,
|
||||
frequencyBands,
|
||||
beamforming: Object.freeze({
|
||||
normalization,
|
||||
compensateFrequencyResponse,
|
||||
maxCompensationDb,
|
||||
}),
|
||||
});
|
||||
const dasPlan = createDasAnalysisPlan({
|
||||
sampleRate,
|
||||
arrayDefinition,
|
||||
scene,
|
||||
experiment,
|
||||
maximumSteeringCacheBytes: config.maximumSteeringCacheBytes,
|
||||
maximumPlanBytes: config.maximumPlanBytes,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
kind: REALTIME_LOCALIZATION_PLAN_KIND,
|
||||
sampleRate,
|
||||
channelCount: arrayDefinition.microphones.length,
|
||||
arrayDefinition,
|
||||
scan,
|
||||
bands: frequencyBands,
|
||||
peakDetection,
|
||||
stft: experiment.stft,
|
||||
speedOfSound,
|
||||
normalization,
|
||||
maxCompensationDb,
|
||||
scene,
|
||||
experiment,
|
||||
dasPlan,
|
||||
computeOptions: Object.freeze({
|
||||
pointBatchSize: DEFAULT_REALTIME_POINT_BATCH_SIZE,
|
||||
stftTransformBatchSize: DEFAULT_REALTIME_STFT_TRANSFORM_BATCH_SIZE,
|
||||
...(config.computeOptions ?? {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function requireLocalizationPlan(plan) {
|
||||
if (plan?.kind !== REALTIME_LOCALIZATION_PLAN_KIND) {
|
||||
throw new TypeError("plan must be created by createLocalizationPlan().");
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Localize one already-sampled multichannel block.
|
||||
*/
|
||||
export async function localizeMultichannel(options) {
|
||||
if (!options || typeof options !== "object") throw new TypeError("options must be an object.");
|
||||
const plan = options.plan
|
||||
? requireLocalizationPlan(options.plan)
|
||||
: createLocalizationPlan(options);
|
||||
const sampleCount = validateChannels(options.channels, plan.channelCount);
|
||||
if (sampleCount === 0) throw new RangeError("channels must contain at least one sample.");
|
||||
throwIfAborted(options.signal);
|
||||
const processingStartedAt = monotonicNow();
|
||||
const das = await computeDasMap({
|
||||
...plan.computeOptions,
|
||||
...(options.computeOptions ?? {}),
|
||||
channels: options.channels,
|
||||
sampleRate: plan.sampleRate,
|
||||
arrayDefinition: plan.arrayDefinition,
|
||||
scene: plan.scene,
|
||||
experiment: plan.experiment,
|
||||
analysisPlan: plan.dasPlan,
|
||||
signal: options.signal,
|
||||
onProgress: options.onProgress,
|
||||
});
|
||||
const locations = locationsFromBands(das.bands, plan.scan, plan.peakDetection);
|
||||
const processingTimeMs = Math.max(0, monotonicNow() - processingStartedAt);
|
||||
return {
|
||||
...das,
|
||||
sampleRate: plan.sampleRate,
|
||||
sampleCount,
|
||||
duration: sampleCount / plan.sampleRate,
|
||||
processingTimeMs,
|
||||
locations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A fixed-capacity chunk buffer for real microphone adapters.
|
||||
*
|
||||
* append() is synchronous and remains safe while an analysis is awaiting CPU
|
||||
* batches. analyzeAvailable() copies one window before yielding, commits its
|
||||
* hop only after successful localization, and returns no more than
|
||||
* maximumResultsPerCall maps.
|
||||
*/
|
||||
export class StreamingDasLocalizer {
|
||||
constructor(config) {
|
||||
this.plan = createLocalizationPlan(config);
|
||||
this.analysisWindowSamples = requirePositiveInteger(
|
||||
config.analysisWindowSamples ??
|
||||
Math.max(this.plan.stft.fftSize, Math.ceil(this.plan.sampleRate / 10)),
|
||||
"analysisWindowSamples",
|
||||
);
|
||||
if (this.analysisWindowSamples < this.plan.stft.fftSize) {
|
||||
throw new RangeError("analysisWindowSamples must be at least fftSize.");
|
||||
}
|
||||
this.analysisHopSamples = requirePositiveInteger(
|
||||
config.analysisHopSamples ?? this.analysisWindowSamples,
|
||||
"analysisHopSamples",
|
||||
);
|
||||
if (this.analysisHopSamples > this.analysisWindowSamples) {
|
||||
throw new RangeError("analysisHopSamples must not exceed analysisWindowSamples.");
|
||||
}
|
||||
this.analysisDeadlineMs = this.analysisHopSamples / this.plan.sampleRate * 1000;
|
||||
this.maximumBufferedSamples = requirePositiveInteger(
|
||||
config.maximumBufferedSamples ?? this.analysisWindowSamples * 4,
|
||||
"maximumBufferedSamples",
|
||||
);
|
||||
if (this.maximumBufferedSamples < this.analysisWindowSamples) {
|
||||
throw new RangeError("maximumBufferedSamples must be at least analysisWindowSamples.");
|
||||
}
|
||||
this.maximumResultsPerCall = requirePositiveInteger(
|
||||
config.maximumResultsPerCall ?? DEFAULT_STREAM_RESULTS_PER_CALL,
|
||||
"maximumResultsPerCall",
|
||||
);
|
||||
if (this.maximumResultsPerCall > MAX_STREAM_RESULTS_PER_CALL) {
|
||||
throw new RangeError(
|
||||
`maximumResultsPerCall must not exceed ${MAX_STREAM_RESULTS_PER_CALL}.`,
|
||||
);
|
||||
}
|
||||
this.maximumInputBufferBytes = requirePositiveInteger(
|
||||
config.maximumInputBufferBytes ?? DEFAULT_MAXIMUM_STREAM_INPUT_BYTES,
|
||||
"maximumInputBufferBytes",
|
||||
);
|
||||
this.retainedInputBytes =
|
||||
this.plan.channelCount *
|
||||
(this.maximumBufferedSamples + this.analysisWindowSamples) *
|
||||
Float32Array.BYTES_PER_ELEMENT;
|
||||
if (
|
||||
!Number.isSafeInteger(this.retainedInputBytes) ||
|
||||
this.retainedInputBytes > this.maximumInputBufferBytes
|
||||
) {
|
||||
throw new RangeError(
|
||||
"stream input buffers require " + this.retainedInputBytes.toLocaleString() +
|
||||
" bytes, above maximumInputBufferBytes " +
|
||||
this.maximumInputBufferBytes.toLocaleString() + ".",
|
||||
);
|
||||
}
|
||||
|
||||
this.buffers = Array.from(
|
||||
{ length: this.plan.channelCount },
|
||||
() => new Float32Array(this.maximumBufferedSamples),
|
||||
);
|
||||
this.analysisChannels = Array.from(
|
||||
{ length: this.plan.channelCount },
|
||||
() => new Float32Array(this.analysisWindowSamples),
|
||||
);
|
||||
this.startIndex = 0;
|
||||
this.bufferedSamples = 0;
|
||||
this.bufferStartSample = 0;
|
||||
this.totalReceivedSamples = 0;
|
||||
this.droppedSamples = 0;
|
||||
this.analysisAttempts = 0;
|
||||
this.completedAnalyses = 0;
|
||||
this.failedAnalyses = 0;
|
||||
this.lastProcessingTimeMs = null;
|
||||
this.maximumProcessingTimeMs = 0;
|
||||
this.deadlineMisses = 0;
|
||||
this.nextSequence = 0;
|
||||
this.pendingAnalysis = null;
|
||||
this.busy = false;
|
||||
}
|
||||
|
||||
availableAnalysisCount() {
|
||||
const bufferEndSample = this.bufferStartSample + this.bufferedSamples;
|
||||
if (!this.pendingAnalysis) {
|
||||
if (this.bufferedSamples < this.analysisWindowSamples) return 0;
|
||||
return 1 + Math.floor(
|
||||
(this.bufferedSamples - this.analysisWindowSamples) / this.analysisHopSamples,
|
||||
);
|
||||
}
|
||||
|
||||
// The failed window remains in analysisChannels even if capture overflow
|
||||
// advances the ring while localization is running. Count it once, then
|
||||
// count windows that will remain after its successful hop commit.
|
||||
const nextRingStart = Math.max(
|
||||
this.bufferStartSample,
|
||||
this.pendingAnalysis.startSample + this.analysisHopSamples,
|
||||
);
|
||||
const futureSamples = bufferEndSample - nextRingStart;
|
||||
const futureCount = futureSamples < this.analysisWindowSamples
|
||||
? 0
|
||||
: 1 + Math.floor(
|
||||
(futureSamples - this.analysisWindowSamples) / this.analysisHopSamples,
|
||||
);
|
||||
return 1 + futureCount;
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
sampleRate: this.plan.sampleRate,
|
||||
channelCount: this.plan.channelCount,
|
||||
analysisWindowSamples: this.analysisWindowSamples,
|
||||
analysisHopSamples: this.analysisHopSamples,
|
||||
analysisDeadlineMs: this.analysisDeadlineMs,
|
||||
maximumBufferedSamples: this.maximumBufferedSamples,
|
||||
maximumInputBufferBytes: this.maximumInputBufferBytes,
|
||||
maximumResultsPerCall: this.maximumResultsPerCall,
|
||||
bufferedSamples: this.bufferedSamples,
|
||||
availableAnalysisCount: this.availableAnalysisCount(),
|
||||
bufferStartSample: this.bufferStartSample,
|
||||
totalReceivedSamples: this.totalReceivedSamples,
|
||||
droppedSamples: this.droppedSamples,
|
||||
analysisAttempts: this.analysisAttempts,
|
||||
completedAnalyses: this.completedAnalyses,
|
||||
failedAnalyses: this.failedAnalyses,
|
||||
lastProcessingTimeMs: this.lastProcessingTimeMs,
|
||||
maximumProcessingTimeMs: this.maximumProcessingTimeMs,
|
||||
deadlineMisses: this.deadlineMisses,
|
||||
nextSequence: this.nextSequence,
|
||||
busy: this.busy,
|
||||
retainedInputBytes: this.retainedInputBytes,
|
||||
};
|
||||
}
|
||||
|
||||
consume(sampleCount, countAsDropped) {
|
||||
if (sampleCount <= 0) return;
|
||||
this.startIndex = (this.startIndex + sampleCount) % this.maximumBufferedSamples;
|
||||
this.bufferedSamples -= sampleCount;
|
||||
this.bufferStartSample += sampleCount;
|
||||
if (countAsDropped) this.droppedSamples += sampleCount;
|
||||
}
|
||||
|
||||
append(channels) {
|
||||
const chunkSamples = validateChannels(channels, this.plan.channelCount);
|
||||
if (chunkSamples === 0) return this.getState();
|
||||
const capacity = this.maximumBufferedSamples;
|
||||
const receivedBefore = this.totalReceivedSamples;
|
||||
|
||||
if (chunkSamples >= capacity) {
|
||||
const retainedStart = chunkSamples - capacity;
|
||||
for (let channelIndex = 0; channelIndex < this.plan.channelCount; channelIndex += 1) {
|
||||
this.buffers[channelIndex].set(channels[channelIndex].subarray(retainedStart));
|
||||
}
|
||||
this.droppedSamples += this.bufferedSamples + retainedStart;
|
||||
this.startIndex = 0;
|
||||
this.bufferedSamples = capacity;
|
||||
this.bufferStartSample = receivedBefore + retainedStart;
|
||||
this.totalReceivedSamples += chunkSamples;
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
const overflow = Math.max(0, this.bufferedSamples + chunkSamples - capacity);
|
||||
this.consume(overflow, true);
|
||||
const writeIndex = (this.startIndex + this.bufferedSamples) % capacity;
|
||||
const firstLength = Math.min(chunkSamples, capacity - writeIndex);
|
||||
for (let channelIndex = 0; channelIndex < this.plan.channelCount; channelIndex += 1) {
|
||||
const source = channels[channelIndex];
|
||||
const target = this.buffers[channelIndex];
|
||||
target.set(source.subarray(0, firstLength), writeIndex);
|
||||
if (firstLength < chunkSamples) {
|
||||
target.set(source.subarray(firstLength), 0);
|
||||
}
|
||||
}
|
||||
this.bufferedSamples += chunkSamples;
|
||||
this.totalReceivedSamples += chunkSamples;
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
copyAnalysisWindow() {
|
||||
const firstLength = Math.min(
|
||||
this.analysisWindowSamples,
|
||||
this.maximumBufferedSamples - this.startIndex,
|
||||
);
|
||||
for (let channelIndex = 0; channelIndex < this.plan.channelCount; channelIndex += 1) {
|
||||
const source = this.buffers[channelIndex];
|
||||
const target = this.analysisChannels[channelIndex];
|
||||
target.set(source.subarray(this.startIndex, this.startIndex + firstLength), 0);
|
||||
if (firstLength < this.analysisWindowSamples) {
|
||||
target.set(
|
||||
source.subarray(0, this.analysisWindowSamples - firstLength),
|
||||
firstLength,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async analyzeAvailable(options = {}) {
|
||||
if (this.busy) throw new Error("analyzeAvailable() is already running.");
|
||||
const maximumResults = options.maximumResults ?? this.maximumResultsPerCall;
|
||||
requirePositiveInteger(maximumResults, "maximumResults");
|
||||
if (maximumResults > this.maximumResultsPerCall) {
|
||||
throw new RangeError(
|
||||
`maximumResults cannot exceed configured bound ${this.maximumResultsPerCall}.`,
|
||||
);
|
||||
}
|
||||
|
||||
this.busy = true;
|
||||
const results = [];
|
||||
try {
|
||||
while (
|
||||
results.length < maximumResults &&
|
||||
(this.pendingAnalysis || this.bufferedSamples >= this.analysisWindowSamples)
|
||||
) {
|
||||
throwIfAborted(options.signal);
|
||||
const retry = this.pendingAnalysis;
|
||||
const startSample = retry?.startSample ?? this.bufferStartSample;
|
||||
const endSample = startSample + this.analysisWindowSamples;
|
||||
if (!retry) this.copyAnalysisWindow();
|
||||
this.analysisAttempts += 1;
|
||||
let localized;
|
||||
try {
|
||||
localized = await localizeMultichannel({
|
||||
plan: this.plan,
|
||||
channels: this.analysisChannels,
|
||||
signal: options.signal,
|
||||
onProgress: options.onProgress,
|
||||
computeOptions: options.computeOptions,
|
||||
});
|
||||
} catch (error) {
|
||||
this.pendingAnalysis = { startSample, endSample };
|
||||
this.failedAnalyses += 1;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = {
|
||||
sequence: this.nextSequence,
|
||||
startSample,
|
||||
endSample,
|
||||
startTime: startSample / this.plan.sampleRate,
|
||||
endTime: endSample / this.plan.sampleRate,
|
||||
centerTime: (startSample + endSample) / (2 * this.plan.sampleRate),
|
||||
...localized,
|
||||
deadlineMs: this.analysisDeadlineMs,
|
||||
deadlineMet: localized.processingTimeMs <= this.analysisDeadlineMs,
|
||||
};
|
||||
this.pendingAnalysis = null;
|
||||
const commitThroughSample = startSample + this.analysisHopSamples;
|
||||
this.consume(
|
||||
Math.min(
|
||||
this.bufferedSamples,
|
||||
Math.max(0, commitThroughSample - this.bufferStartSample),
|
||||
),
|
||||
false,
|
||||
);
|
||||
this.nextSequence += 1;
|
||||
this.completedAnalyses += 1;
|
||||
this.lastProcessingTimeMs = localized.processingTimeMs;
|
||||
this.maximumProcessingTimeMs = Math.max(
|
||||
this.maximumProcessingTimeMs,
|
||||
localized.processingTimeMs,
|
||||
);
|
||||
if (!result.deadlineMet) this.deadlineMisses += 1;
|
||||
results.push(result);
|
||||
|
||||
// Consumer failures must not turn a successfully committed analysis
|
||||
// into a localization failure or make its window eligible for retry.
|
||||
options.onResult?.(result);
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async push(channels, options = {}) {
|
||||
this.append(channels);
|
||||
return this.analyzeAvailable(options);
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (this.busy) throw new Error("cannot reset while analysis is running.");
|
||||
this.startIndex = 0;
|
||||
this.bufferedSamples = 0;
|
||||
this.bufferStartSample = 0;
|
||||
this.totalReceivedSamples = 0;
|
||||
this.droppedSamples = 0;
|
||||
this.analysisAttempts = 0;
|
||||
this.completedAnalyses = 0;
|
||||
this.failedAnalyses = 0;
|
||||
this.lastProcessingTimeMs = null;
|
||||
this.maximumProcessingTimeMs = 0;
|
||||
this.deadlineMisses = 0;
|
||||
this.nextSequence = 0;
|
||||
this.pendingAnalysis = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createStreamingLocalizer(config) {
|
||||
return new StreamingDasLocalizer(config);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fftInPlace } from "./fft.js";
|
||||
import { cooperativeYield } from "./cooperative-yield.js";
|
||||
|
||||
function throwIfAborted(signal) {
|
||||
if (signal?.aborted) {
|
||||
@@ -8,10 +9,6 @@ function throwIfAborted(signal) {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -21,6 +18,32 @@ export function hannWindow(size) {
|
||||
return window;
|
||||
}
|
||||
|
||||
const STFT_WORKSPACE_KIND = "micarrsim.stft-workspace/1";
|
||||
|
||||
export function createStftWorkspace(fftSize) {
|
||||
return {
|
||||
kind: STFT_WORKSPACE_KIND,
|
||||
fftSize,
|
||||
window: hannWindow(fftSize),
|
||||
real: new Float64Array(fftSize),
|
||||
imaginary: new Float64Array(fftSize),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStftWorkspace(fftSize, workspace) {
|
||||
if (workspace === undefined) return createStftWorkspace(fftSize);
|
||||
if (
|
||||
workspace?.kind !== STFT_WORKSPACE_KIND ||
|
||||
workspace.fftSize !== fftSize ||
|
||||
workspace.window?.length !== fftSize ||
|
||||
workspace.real?.length !== fftSize ||
|
||||
workspace.imaginary?.length !== fftSize
|
||||
) {
|
||||
throw new RangeError("STFT workspace must match fftSize.");
|
||||
}
|
||||
return workspace;
|
||||
}
|
||||
|
||||
export function getFrameCount(sampleCount, fftSize, hopSize) {
|
||||
if (!Number.isInteger(hopSize) || hopSize < 1 || hopSize > fftSize) {
|
||||
throw new Error("hopSize는 1 이상 fftSize 이하여야 합니다.");
|
||||
@@ -29,7 +52,15 @@ export function getFrameCount(sampleCount, fftSize, hopSize) {
|
||||
}
|
||||
|
||||
export async function extractStftBins(channels, options) {
|
||||
const { fftSize, hopSize, bins, signal, onProgress, transformBatchSize = 32 } = options;
|
||||
const {
|
||||
fftSize,
|
||||
hopSize,
|
||||
bins,
|
||||
signal,
|
||||
onProgress,
|
||||
transformBatchSize = 32,
|
||||
workspace: providedWorkspace,
|
||||
} = 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("마이크 채널 길이가 다릅니다.");
|
||||
@@ -51,9 +82,8 @@ export async function extractStftBins(channels, options) {
|
||||
});
|
||||
}
|
||||
|
||||
const window = hannWindow(fftSize);
|
||||
const real = new Float64Array(fftSize);
|
||||
const imaginary = new Float64Array(fftSize);
|
||||
const workspace = resolveStftWorkspace(fftSize, providedWorkspace);
|
||||
const { window, real, imaginary } = workspace;
|
||||
const totalTransforms = frameCount * microphoneCount;
|
||||
let completedTransforms = 0;
|
||||
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
||||
@@ -73,9 +103,12 @@ export async function extractStftBins(channels, options) {
|
||||
spectrum.imaginary[spectrumIndex] = imaginary[bin];
|
||||
}
|
||||
completedTransforms += 1;
|
||||
if (completedTransforms % transformBatchSize === 0) {
|
||||
if (
|
||||
completedTransforms % transformBatchSize === 0 &&
|
||||
completedTransforms < totalTransforms
|
||||
) {
|
||||
onProgress?.({ completedTransforms, totalTransforms });
|
||||
await yieldToEventLoop();
|
||||
await cooperativeYield();
|
||||
throwIfAborted(signal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ export const SCHEMA_VERSION = "1.0.0";
|
||||
const MAX_MICROPHONES = 1024;
|
||||
const MAX_SOURCES = 32;
|
||||
const MAX_GRID_SIDE = 512;
|
||||
const MAX_FREQUENCY_RESPONSE_POINTS = 64;
|
||||
const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000;
|
||||
|
||||
/** @typedef {{ path: string, message: string }} ValidationIssue */
|
||||
|
||||
@@ -65,6 +67,28 @@ function requirePosition(value, path, issues) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function requireBounds(value, path, issues) {
|
||||
if (!requireRecord(value, path, issues)) return false;
|
||||
const minimumValid = requirePosition(value.min, path + ".min", issues);
|
||||
const maximumValid = requirePosition(value.max, path + ".max", issues);
|
||||
if (!minimumValid || !maximumValid) return false;
|
||||
let ordered = true;
|
||||
for (let axis = 0; axis < 3; axis += 1) {
|
||||
if (value.min[axis] >= value.max[axis]) {
|
||||
addIssue(issues, path, "min 좌표는 모든 축에서 max 좌표보다 작아야 합니다.");
|
||||
ordered = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function boundsContain(bounds, position) {
|
||||
return position.every(
|
||||
(coordinate, axis) => coordinate >= bounds.min[axis] && coordinate <= bounds.max[axis],
|
||||
);
|
||||
}
|
||||
|
||||
function validateHeader(value, expectedKind, issues) {
|
||||
if (!requireRecord(value, "$", issues)) return false;
|
||||
if (value.schemaVersion !== SCHEMA_VERSION) {
|
||||
@@ -131,6 +155,37 @@ function validateArrayRelationships(value) {
|
||||
if (microphone.sensitivityDb !== undefined) {
|
||||
requireFinite(microphone.sensitivityDb, `${path}.sensitivityDb`, issues, { min: -120, max: 120 });
|
||||
}
|
||||
if (microphone.frequencyResponse !== undefined) {
|
||||
const responsePath = `${path}.frequencyResponse`;
|
||||
if (!Array.isArray(microphone.frequencyResponse)) {
|
||||
addIssue(issues, responsePath, "주파수 응답점 배열이어야 합니다.");
|
||||
} else {
|
||||
if (microphone.frequencyResponse.length < 2 || microphone.frequencyResponse.length > MAX_FREQUENCY_RESPONSE_POINTS) {
|
||||
addIssue(issues, responsePath, `주파수 응답점은 2~${MAX_FREQUENCY_RESPONSE_POINTS}개여야 합니다.`);
|
||||
}
|
||||
let previousFrequency = -Infinity;
|
||||
microphone.frequencyResponse.forEach((point, pointIndex) => {
|
||||
const pointPath = `${responsePath}[${pointIndex}]`;
|
||||
if (!requireRecord(point, pointPath, issues)) return;
|
||||
const frequencyValid = requireFinite(point.frequencyHz, `${pointPath}.frequencyHz`, issues, {
|
||||
min: 0,
|
||||
max: MAX_FREQUENCY_RESPONSE_HZ,
|
||||
});
|
||||
requireFinite(point.gainDb, `${pointPath}.gainDb`, issues, { min: -120, max: 120 });
|
||||
if (point.phaseDeg !== undefined) {
|
||||
requireFinite(point.phaseDeg, `${pointPath}.phaseDeg`, issues, { min: -180, max: 180 });
|
||||
}
|
||||
if (frequencyValid && point.frequencyHz <= previousFrequency) {
|
||||
addIssue(
|
||||
issues,
|
||||
`${pointPath}.frequencyHz`,
|
||||
"주파수 응답점은 frequencyHz가 중복 없이 엄격히 증가하도록 정렬하세요.",
|
||||
);
|
||||
}
|
||||
if (frequencyValid) previousFrequency = point.frequencyHz;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const duplicate of findDuplicates(ids)) {
|
||||
addIssue(issues, "$.microphones", `중복 마이크 id: ${duplicate}`);
|
||||
@@ -178,6 +233,44 @@ function validateSceneRelationships(value) {
|
||||
requireFinite(value.speedOfSound, "$.speedOfSound", issues, { min: 250, max: 400 });
|
||||
requireInteger(value.seed, "$.seed", issues, { min: 0, max: 0xffffffff });
|
||||
|
||||
let acousticRoom = null;
|
||||
if (value.acoustics !== undefined && requireRecord(value.acoustics, "$.acoustics", issues)) {
|
||||
const acoustics = value.acoustics;
|
||||
if (acoustics.model === "free-field") {
|
||||
const unusedFields = ["room", "reflections", "diffuseReverb", "occluders"]
|
||||
.filter((key) => acoustics[key] !== undefined);
|
||||
if (unusedFields.length > 0) {
|
||||
addIssue(
|
||||
issues,
|
||||
"$.acoustics",
|
||||
"free-field 모델에서는 사용되지 않는 설정을 제거하세요: " + unusedFields.join(", "),
|
||||
);
|
||||
}
|
||||
} else if (acoustics.model === "shoebox") {
|
||||
if (acoustics.room === undefined) {
|
||||
addIssue(issues, "$.acoustics.room", "shoebox 모델에는 room 경계가 필요합니다.");
|
||||
} else if (requireBounds(acoustics.room, "$.acoustics.room", issues)) {
|
||||
acousticRoom = acoustics.room;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(acoustics.occluders)) {
|
||||
const occluderIds = [];
|
||||
acoustics.occluders.forEach((occluder, index) => {
|
||||
const path = "$.acoustics.occluders[" + index + "]";
|
||||
if (!requireRecord(occluder, path, issues)) return;
|
||||
if (requireString(occluder.id, path + ".id", issues)) occluderIds.push(occluder.id);
|
||||
if (requireBounds(occluder, path, issues) && acousticRoom) {
|
||||
if (!boundsContain(acousticRoom, occluder.min) || !boundsContain(acousticRoom, occluder.max)) {
|
||||
addIssue(issues, path, "occluder는 room 경계 안에 있어야 합니다.");
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const duplicate of findDuplicates(occluderIds)) {
|
||||
addIssue(issues, "$.acoustics.occluders", "중복 occluder id: " + duplicate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const assets = Array.isArray(value.assets) ? value.assets : [];
|
||||
const assetsById = new Map();
|
||||
const allIds = [];
|
||||
@@ -231,7 +324,10 @@ function validateSceneRelationships(value) {
|
||||
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);
|
||||
const sourcePositionValid = requirePosition(source.position, `${path}.position`, issues);
|
||||
if (sourcePositionValid && acousticRoom && !boundsContain(acousticRoom, source.position)) {
|
||||
addIssue(issues, `${path}.position`, "shoebox room 경계 안에 있어야 합니다.");
|
||||
}
|
||||
if (Array.isArray(source.trajectory)) {
|
||||
let previousTime = -Infinity;
|
||||
source.trajectory.forEach((keyframe, keyframeIndex) => {
|
||||
@@ -241,7 +337,10 @@ function validateSceneRelationships(value) {
|
||||
min: 0,
|
||||
max: sceneDurationValid ? value.duration : 300,
|
||||
});
|
||||
requirePosition(keyframe.position, `${keyframePath}.position`, issues);
|
||||
const keyframePositionValid = requirePosition(keyframe.position, `${keyframePath}.position`, issues);
|
||||
if (keyframePositionValid && acousticRoom && !boundsContain(acousticRoom, keyframe.position)) {
|
||||
addIssue(issues, `${keyframePath}.position`, "shoebox room 경계 안에 있어야 합니다.");
|
||||
}
|
||||
if (timeValid && keyframe.time <= previousTime) {
|
||||
addIssue(issues, `${keyframePath}.time`, "trajectory time은 앞 keyframe보다 커야 합니다.");
|
||||
}
|
||||
@@ -346,6 +445,24 @@ function validateExperimentRelationships(value, context = {}) {
|
||||
max: 10,
|
||||
exclusiveMin: true,
|
||||
});
|
||||
if (
|
||||
value.beamforming.compensateFrequencyResponse !== undefined &&
|
||||
typeof value.beamforming.compensateFrequencyResponse !== "boolean"
|
||||
) {
|
||||
addIssue(
|
||||
issues,
|
||||
"$.beamforming.compensateFrequencyResponse",
|
||||
"주파수 응답 보정 사용 여부는 true 또는 false여야 합니다.",
|
||||
);
|
||||
}
|
||||
if (value.beamforming.maxCompensationDb !== undefined) {
|
||||
requireFinite(
|
||||
value.beamforming.maxCompensationDb,
|
||||
"$.beamforming.maxCompensationDb",
|
||||
issues,
|
||||
{ min: 0, max: 120 },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (value.evaluation !== undefined && requireRecord(value.evaluation, "$.evaluation", issues)) {
|
||||
const threshold = value.evaluation.detectionThresholdRatio;
|
||||
|
||||
@@ -396,3 +396,218 @@ p {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Compact, task-first landing page. */
|
||||
.landing-header {
|
||||
display: flex;
|
||||
width: min(1120px, calc(100% - 40px));
|
||||
min-height: 64px;
|
||||
margin: 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.landing-brand,
|
||||
.landing-docs {
|
||||
color: inherit;
|
||||
font-weight: 760;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.landing-brand {
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.landing-docs {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.landing-docs:hover,
|
||||
.landing-docs:focus-visible {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.landing-header .status-row {
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.landing-shell {
|
||||
width: min(960px, calc(100% - 40px));
|
||||
padding: clamp(44px, 8vw, 84px) 0 36px;
|
||||
}
|
||||
|
||||
.landing-intro {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.landing-intro .eyebrow {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.landing-intro h1 {
|
||||
margin-bottom: 12px;
|
||||
font-size: clamp(2.25rem, 6vw, 4.2rem);
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.065em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.landing-intro > p:last-child {
|
||||
margin-bottom: 0;
|
||||
color: #c8d9d3;
|
||||
font-size: clamp(0.95rem, 2vw, 1.1rem);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.landing-shell .launcher-grid {
|
||||
gap: 16px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.landing-shell .launcher-card {
|
||||
grid-template-rows: auto 1fr auto;
|
||||
min-height: 250px;
|
||||
padding: clamp(22px, 4vw, 30px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 16px 48px rgb(0 0 0 / 20%);
|
||||
}
|
||||
|
||||
.landing-shell .launcher-card:hover,
|
||||
.landing-shell .launcher-card:focus-visible {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.launcher-card__number {
|
||||
font: 750 0.7rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
.launcher-card__kicker {
|
||||
margin: 28px 0 6px !important;
|
||||
color: var(--mint) !important;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.launcher-card--accent .launcher-card__kicker {
|
||||
color: var(--cyan) !important;
|
||||
}
|
||||
|
||||
.landing-shell .launcher-card h2 {
|
||||
margin-bottom: 10px;
|
||||
font-size: clamp(1.45rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.landing-shell .launcher-card p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.landing-shell .launcher-card__action {
|
||||
margin-top: 24px;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.workflow-disclosure {
|
||||
margin-top: 16px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: rgb(255 255 255 / 2%);
|
||||
}
|
||||
|
||||
.workflow-disclosure summary {
|
||||
padding: 16px 0;
|
||||
color: #c8d9d3;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workflow-disclosure[open] summary {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.workflow-disclosure .workflow-list {
|
||||
margin: 16px 0 12px;
|
||||
}
|
||||
|
||||
.workflow-disclosure .workflow-list li {
|
||||
min-height: 78px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.workflow-disclosure .workflow-list li::before {
|
||||
margin-bottom: 9px;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.workflow-disclosure .workflow-list strong {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.workflow-disclosure .workflow-list span {
|
||||
margin-top: 4px;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.workflow-disclosure .workflow-note {
|
||||
margin: 0 0 16px;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.workflow-disclosure .workflow-note a {
|
||||
color: var(--mint);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.landing-header {
|
||||
min-height: 58px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.landing-header .status-chip--neutral {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.landing-shell .launcher-card {
|
||||
min-height: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.landing-header,
|
||||
.landing-shell {
|
||||
width: min(100% - 28px, 960px);
|
||||
}
|
||||
|
||||
.landing-header {
|
||||
flex-wrap: wrap;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.landing-header .status-row {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.landing-shell {
|
||||
padding-top: 36px;
|
||||
}
|
||||
|
||||
.landing-intro h1 {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.landing-shell .launcher-card {
|
||||
min-height: 205px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ import {
|
||||
estimateDasWorkload,
|
||||
finalizeDasBands,
|
||||
} from "../acoustics-core/das.js";
|
||||
import {
|
||||
interpolateFrequencyResponse,
|
||||
inverseResponseGainDb,
|
||||
requireMaxCompensationDb,
|
||||
} from "../acoustics-core/frequency-response.js";
|
||||
import { dbToLinear } from "../acoustics-core/math.js";
|
||||
import { extractStftBins } from "../acoustics-core/stft.js";
|
||||
import {
|
||||
DAS_PARAMS_BYTE_LENGTH,
|
||||
@@ -66,13 +72,48 @@ function minimumBufferSize(byteLength) {
|
||||
return Math.max(4, Math.ceil(byteLength / 4) * 4);
|
||||
}
|
||||
|
||||
export function packStftBatch(stft, bins) {
|
||||
export function packStftBatch(stft, bins, options = {}) {
|
||||
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);
|
||||
const microphones = options.microphones;
|
||||
const compensateFrequencyResponse = options.compensateFrequencyResponse !== false;
|
||||
const maxCompensationDb = requireMaxCompensationDb(options.maxCompensationDb);
|
||||
const hasFrequencyResponse =
|
||||
compensateFrequencyResponse &&
|
||||
Array.isArray(microphones) &&
|
||||
microphones.some((microphone) => Array.isArray(microphone.frequencyResponse));
|
||||
if (hasFrequencyResponse) {
|
||||
if (microphones.length !== stft.microphoneCount) {
|
||||
throw new Error("STFT microphone 수와 주파수 응답 calibration 수가 다릅니다.");
|
||||
}
|
||||
if (!(options.sampleRate > 0) || !(options.fftSize > 0)) {
|
||||
throw new Error("주파수 응답 보정에는 유효한 sampleRate와 fftSize가 필요합니다.");
|
||||
}
|
||||
}
|
||||
const inverseResponses = hasFrequencyResponse
|
||||
? new Float64Array(bins.length * stft.microphoneCount * 2)
|
||||
: null;
|
||||
if (hasFrequencyResponse) {
|
||||
for (let index = 0; index < inverseResponses.length; index += 2) inverseResponses[index] = 1;
|
||||
const response = {};
|
||||
bins.forEach((bin, binIndex) => {
|
||||
const frequencyHz = (bin * options.sampleRate) / options.fftSize;
|
||||
microphones.forEach((microphone, microphoneIndex) => {
|
||||
if (!Array.isArray(microphone.frequencyResponse)) return;
|
||||
interpolateFrequencyResponse(microphone.frequencyResponse, frequencyHz, response);
|
||||
const inverseGain = dbToLinear(
|
||||
inverseResponseGainDb(response.gainDb, maxCompensationDb),
|
||||
);
|
||||
const responseOffset = (binIndex * stft.microphoneCount + microphoneIndex) * 2;
|
||||
inverseResponses[responseOffset] = inverseGain * Math.cos(response.phaseRadians);
|
||||
inverseResponses[responseOffset + 1] = -inverseGain * Math.sin(response.phaseRadians);
|
||||
});
|
||||
});
|
||||
}
|
||||
bins.forEach((bin, binIndex) => {
|
||||
const spectrum = stft.spectra.get(bin);
|
||||
if (!spectrum || spectrum.real.length !== valuesPerBin || spectrum.imaginary.length !== valuesPerBin) {
|
||||
@@ -80,8 +121,21 @@ export function packStftBatch(stft, bins) {
|
||||
}
|
||||
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];
|
||||
if (!inverseResponses) {
|
||||
packed[binOffset + index * 2] = spectrum.real[index];
|
||||
packed[binOffset + index * 2 + 1] = spectrum.imaginary[index];
|
||||
continue;
|
||||
}
|
||||
const microphoneIndex = index % stft.microphoneCount;
|
||||
const responseOffset = (binIndex * stft.microphoneCount + microphoneIndex) * 2;
|
||||
const inverseReal = inverseResponses[responseOffset];
|
||||
const inverseImaginary = inverseResponses[responseOffset + 1];
|
||||
const inputReal = spectrum.real[index];
|
||||
const inputImaginary = spectrum.imaginary[index];
|
||||
packed[binOffset + index * 2] =
|
||||
inputReal * inverseReal - inputImaginary * inverseImaginary;
|
||||
packed[binOffset + index * 2 + 1] =
|
||||
inputReal * inverseImaginary + inputImaginary * inverseReal;
|
||||
}
|
||||
});
|
||||
return packed;
|
||||
@@ -488,7 +542,14 @@ export async function computeDasWebGpu(options) {
|
||||
spectrumBatchCount: plan.binBatchCount,
|
||||
}),
|
||||
});
|
||||
const packedSpectrum = packStftBatch(stft, batchBins);
|
||||
const packedSpectrum = packStftBatch(stft, batchBins, {
|
||||
microphones: arrayDefinition.microphones,
|
||||
sampleRate,
|
||||
fftSize: experiment.stft.fftSize,
|
||||
compensateFrequencyResponse:
|
||||
experiment.beamforming.compensateFrequencyResponse !== false,
|
||||
maxCompensationDb: experiment.beamforming.maxCompensationDb,
|
||||
});
|
||||
stftMs += now() - stftStartedAt;
|
||||
const binIndices = Uint32Array.from(batchBins);
|
||||
const binResources = [];
|
||||
|
||||
+11
-3
@@ -4,9 +4,17 @@
|
||||
|
||||
JSON Schema Draft 2020-12 형식입니다.
|
||||
|
||||
- `array.schema.json`: 배열 크기, 좌표계, 마이크 위치와 선택적 보정값
|
||||
- `scene.schema.json`: 재현 가능한 음원 recipe, asset 참조, 정적 위치와 선택적 trajectory
|
||||
- `array.schema.json`: 배열 크기, 좌표계, 마이크 위치와 선택적 scalar/주파수 응답 보정값
|
||||
- `scene.schema.json`: 음원 recipe, asset·trajectory와 선택적 sparse shoebox 음향 환경
|
||||
- `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT/DAS와 peak 검출 임계값
|
||||
- `result.schema.json`: raw/display/후처리 map, 복수 peak, 비확률 beam spread, timing과 평가 지표
|
||||
|
||||
중복 id, Nyquist, 시간 구간과 참조 무결성은 `packages/contracts/validation.js`에서 추가로 검증합니다.
|
||||
마이크 주파수 응답점은 배열 계약 자체이므로 특정 Scene의 Nyquist와 결합하지 않고 0~1 MHz의
|
||||
안전 범위에서 엄격한 오름차순을 요구합니다. 실제 분석에서는 선택한 Scene/STFT의 bin 주파수만
|
||||
보간합니다. 중복 id, Nyquist, 시간 구간과 참조 무결성은
|
||||
`packages/contracts/validation.js`에서 추가로 검증합니다.
|
||||
|
||||
Scene `acoustics`는 기본 `free-field` 또는 `shoebox`입니다. shoebox는 room bounds, 0/1차
|
||||
반사와 scattering 비율, bounded diffuse tail, 최대 8개의 AABB occluder를 저장합니다.
|
||||
room·occluder의 각 축은 `min < max`여야 하고 source/trajectory와 occluder는 room 안에
|
||||
있어야 합니다. 이 계약은 전체 파동 해석이 아니라 sparse path 근사의 입력입니다.
|
||||
|
||||
@@ -42,7 +42,20 @@
|
||||
"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 }
|
||||
"phaseOffsetDeg": { "type": "number", "minimum": -180, "maximum": 180 },
|
||||
"frequencyResponse": {
|
||||
"type": "array", "minItems": 2, "maxItems": 64,
|
||||
"items": { "$ref": "#/$defs/frequencyResponsePoint" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"frequencyResponsePoint": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["frequencyHz", "gainDb"],
|
||||
"properties": {
|
||||
"frequencyHz": { "type": "number", "minimum": 0, "maximum": 1000000 },
|
||||
"gainDb": { "type": "number", "minimum": -120, "maximum": 120 },
|
||||
"phaseDeg": { "type": "number", "minimum": -180, "maximum": 180 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
"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 }
|
||||
"dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 },
|
||||
"compensateFrequencyResponse": { "type": "boolean" },
|
||||
"maxCompensationDb": { "type": "number", "minimum": 0, "maximum": 120 }
|
||||
}
|
||||
},
|
||||
"evaluation": {
|
||||
|
||||
@@ -23,7 +23,19 @@
|
||||
"properties": {
|
||||
"preNormalizationPeak": { "type": "number", "minimum": 0 },
|
||||
"appliedScale": { "type": "number", "minimum": 0 },
|
||||
"clippedSampleCount": { "type": "integer", "minimum": 0 }
|
||||
"clippedSampleCount": { "type": "integer", "minimum": 0 },
|
||||
"acousticModel": { "enum": ["free-field", "shoebox"] },
|
||||
"acousticPathCount": { "type": "integer", "minimum": 0 },
|
||||
"acousticPathCounts": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": {
|
||||
"direct": { "type": "integer", "minimum": 0 },
|
||||
"reflection": { "type": "integer", "minimum": 0 },
|
||||
"scattering": { "type": "integer", "minimum": 0 },
|
||||
"diffraction": { "type": "integer", "minimum": 0 },
|
||||
"diffuse": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"timingMs": {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"targetPeak": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }
|
||||
}
|
||||
},
|
||||
"acoustics": { "$ref": "#/$defs/acoustics" },
|
||||
"image": { "$ref": "#/$defs/image" },
|
||||
"assets": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/asset" } },
|
||||
"sources": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/source" } },
|
||||
@@ -38,6 +39,67 @@
|
||||
"position": { "$ref": "#/$defs/position" }
|
||||
}
|
||||
},
|
||||
"wallLevels": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": {
|
||||
"xMin": { "type": "number", "minimum": -60, "maximum": 0 },
|
||||
"xMax": { "type": "number", "minimum": -60, "maximum": 0 },
|
||||
"yMin": { "type": "number", "minimum": -60, "maximum": 0 },
|
||||
"yMax": { "type": "number", "minimum": -60, "maximum": 0 },
|
||||
"zMin": { "type": "number", "minimum": -60, "maximum": 0 },
|
||||
"zMax": { "type": "number", "minimum": -60, "maximum": 0 }
|
||||
}
|
||||
},
|
||||
"room": {
|
||||
"type": "object", "additionalProperties": false, "required": ["min", "max"],
|
||||
"properties": {
|
||||
"min": { "$ref": "#/$defs/position" },
|
||||
"max": { "$ref": "#/$defs/position" }
|
||||
}
|
||||
},
|
||||
"reflections": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["order", "wallGainDb", "scattering"],
|
||||
"properties": {
|
||||
"order": { "enum": [0, 1] },
|
||||
"wallGainDb": { "type": "number", "minimum": -60, "maximum": 0 },
|
||||
"perWallDb": { "$ref": "#/$defs/wallLevels" },
|
||||
"scattering": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||
}
|
||||
},
|
||||
"diffuseReverb": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["enabled", "rt60Seconds", "gainDb", "tapCount"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"rt60Seconds": { "type": "number", "minimum": 0.05, "maximum": 5 },
|
||||
"gainDb": { "type": "number", "minimum": -60, "maximum": 0 },
|
||||
"tapCount": { "type": "integer", "minimum": 1, "maximum": 32 }
|
||||
}
|
||||
},
|
||||
"occluder": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["id", "min", "max", "transmissionDb", "diffractionGainDb"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
|
||||
"min": { "$ref": "#/$defs/position" },
|
||||
"max": { "$ref": "#/$defs/position" },
|
||||
"transmissionDb": { "type": "number", "minimum": -120, "maximum": 0 },
|
||||
"diffractionGainDb": { "type": "number", "minimum": -120, "maximum": 0 }
|
||||
}
|
||||
},
|
||||
"acoustics": {
|
||||
"type": "object", "additionalProperties": false, "required": ["model"],
|
||||
"properties": {
|
||||
"model": { "enum": ["free-field", "shoebox"] },
|
||||
"room": { "$ref": "#/$defs/room" },
|
||||
"reflections": { "$ref": "#/$defs/reflections" },
|
||||
"diffuseReverb": { "$ref": "#/$defs/diffuseReverb" },
|
||||
"occluders": {
|
||||
"type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/occluder" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["assetId", "widthPixels", "heightPixels", "plane"],
|
||||
|
||||
@@ -36,6 +36,33 @@ async function digestHex(blob) {
|
||||
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
test("시작 화면과 Dataset Builder가 작업 우선순위 및 390px 레이아웃을 유지한다", async () => {
|
||||
const landingFrame = document.createElement("iframe");
|
||||
landingFrame.src = "/";
|
||||
landingFrame.style.cssText = "position:fixed;left:-20000px;top:0;width:390px;height:900px;border:0";
|
||||
document.body.append(landingFrame);
|
||||
await new Promise((resolve, reject) => {
|
||||
landingFrame.addEventListener("load", resolve, { once: true });
|
||||
landingFrame.addEventListener("error", () => reject(new Error("Landing iframe load failed")), { once: true });
|
||||
});
|
||||
|
||||
try {
|
||||
const landingDocument = landingFrame.contentDocument;
|
||||
const launchers = [...landingDocument.querySelectorAll(".launcher-card")];
|
||||
assertEqual(launchers.length, 2, "landing exposes only two primary tasks");
|
||||
assert(launchers[0].textContent.includes("Acoustic Camera"), "analysis is the first landing task");
|
||||
assert(launchers[1].textContent.includes("Dataset Builder"), "scene authoring is the second landing task");
|
||||
assert(!landingDocument.querySelector(".workflow-disclosure").open, "file workflow starts collapsed");
|
||||
assert(!landingDocument.querySelector('a[href="/docs/execution-plan.md"]'), "developer plan is absent from landing");
|
||||
assert(
|
||||
landingDocument.documentElement.scrollWidth <= landingDocument.documentElement.clientWidth + 1,
|
||||
"landing has no horizontal overflow at 390px",
|
||||
);
|
||||
} finally {
|
||||
landingFrame.remove();
|
||||
}
|
||||
});
|
||||
|
||||
test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 preview WAV를 재현한다", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.src = "/apps/dataset-builder/?integration-test=1";
|
||||
@@ -61,6 +88,47 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev
|
||||
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 workflowSteps = [...documentObject.querySelectorAll("#builder-workflow [data-workflow-step]")];
|
||||
assertEqual(workflowSteps.length, 4, "four Builder workflow steps are discoverable");
|
||||
assertEqual(
|
||||
workflowSteps.map((step) => step.dataset.workflowStep).join(","),
|
||||
"input,edit,validate,save",
|
||||
"Builder workflow order",
|
||||
);
|
||||
assert(!documentObject.querySelector(".header-more").open, "secondary export actions start collapsed");
|
||||
assert(!documentObject.querySelector(".inline-disclosure").open, "advanced scene settings start collapsed");
|
||||
assert(
|
||||
[...documentObject.querySelectorAll(".disclosure-section")].every((section) => !section.open),
|
||||
"optional plane and ambient settings start collapsed",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#source-editor-section").compareDocumentPosition(
|
||||
documentObject.querySelector("#scene-name").closest(".control-section"),
|
||||
) & windowObject.Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
"selected source editor precedes scene metadata",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#scene-canvas").getBoundingClientRect().height >= 500,
|
||||
"canvas remains the dominant Builder workspace",
|
||||
);
|
||||
|
||||
frame.style.width = "390px";
|
||||
await new Promise((resolve) => windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve)));
|
||||
assert(
|
||||
documentObject.documentElement.scrollWidth <= documentObject.documentElement.clientWidth + 1,
|
||||
"Builder has no horizontal overflow at 390px",
|
||||
);
|
||||
const finalWorkflowStep = workflowSteps.at(-1).getBoundingClientRect();
|
||||
assert(
|
||||
finalWorkflowStep.right <= documentObject.documentElement.clientWidth + 1,
|
||||
"all Builder workflow steps remain visible at 390px",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#scene-canvas").getBoundingClientRect().height >= 350,
|
||||
"Builder canvas remains usable at 390px",
|
||||
);
|
||||
frame.style.width = "1400px";
|
||||
await new Promise((resolve) => windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve)));
|
||||
|
||||
const previewSamples = new Float32Array(2000);
|
||||
for (let index = 0; index < previewSamples.length; index += 1) {
|
||||
|
||||
@@ -60,9 +60,41 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
"기본 Camera 입력 검증",
|
||||
);
|
||||
|
||||
const workflowSteps = [...documentObject.querySelectorAll("#camera-workflow [data-workflow-step]")];
|
||||
assertEqual(workflowSteps.length, 4, "four-step workflow is discoverable");
|
||||
assertEqual(
|
||||
workflowSteps.map((step) => step.dataset.workflowStep).join(","),
|
||||
"1,2,3,4",
|
||||
"workflow order",
|
||||
);
|
||||
assert(documentObject.querySelector("#input-step").textContent.includes("입력 준비"), "input step label");
|
||||
assert(documentObject.querySelector("#configure-step").textContent.includes("분석 설정"), "configure step label");
|
||||
assert(documentObject.querySelector("#run-step").textContent.includes("실행 방식"), "run step label");
|
||||
assert(documentObject.querySelector("#inspect-step").textContent.includes("결과 확인"), "inspect step label");
|
||||
assert(documentObject.querySelector("#snapshot-mode").contains(documentObject.querySelector("#run-button")), "snapshot action is grouped with snapshot mode");
|
||||
assert(documentObject.querySelector("#moving-mode").contains(documentObject.querySelector("#prepare-live-button")), "live action is grouped with moving mode");
|
||||
assert(!documentObject.querySelector("#advanced-settings").open, "advanced settings start collapsed");
|
||||
assert(!documentObject.querySelector("#array-details").open, "array developer detail starts collapsed");
|
||||
assert(!documentObject.querySelector("#camera-help").open, "limitations help starts collapsed");
|
||||
assertEqual(documentObject.querySelector("#acoustic-profile").value, "free-field", "default free-field profile");
|
||||
assertEqual(
|
||||
documentObject.querySelector("#frequency-response-mode").value,
|
||||
"compensate",
|
||||
"known microphone response is compensated by default",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#acoustic-environment-summary").textContent.includes("sparse path"),
|
||||
"bounded acoustic cost is visible",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#acoustic-environment-summary").textContent.includes("≤ 24 dB"),
|
||||
"safe inverse-response boost limit is visible",
|
||||
);
|
||||
|
||||
const canvas = documentObject.querySelector("#scene-canvas");
|
||||
await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더");
|
||||
await nextPaint(windowObject);
|
||||
assert(canvas.getBoundingClientRect().height >= 500, "scene canvas remains the dominant workspace");
|
||||
const initialCanvas = canvas.toDataURL();
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
|
||||
@@ -75,6 +107,11 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
!documentObject.querySelector("#run-button").disabled,
|
||||
"8×8 CPU 입력 검증",
|
||||
);
|
||||
assertEqual(
|
||||
documentObject.querySelector("#run-button").textContent.trim(),
|
||||
"▶ Snapshot 실행",
|
||||
"snapshot label survives runtime validation",
|
||||
);
|
||||
assertEqual(documentObject.querySelector("#grid-preset").value, "custom", "custom grid preset");
|
||||
assert(
|
||||
documentObject.documentElement.dataset.unsavedWork.includes("Experiment"),
|
||||
@@ -89,7 +126,8 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
);
|
||||
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.querySelectorAll("#metric-grid .metric-card").length >= 10, "result metrics rendered");
|
||||
assert(documentObject.querySelector("#metric-grid").textContent.includes("음향 모델"), "acoustic model metric 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");
|
||||
@@ -208,6 +246,18 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
"final Camera documents saved",
|
||||
);
|
||||
assertEqual(documentObject.documentElement.dataset.unsavedWork, "", "all Camera work is saved");
|
||||
|
||||
frame.style.width = "390px";
|
||||
await nextPaint(windowObject);
|
||||
assert(
|
||||
documentObject.documentElement.scrollWidth <= documentObject.documentElement.clientWidth + 1,
|
||||
"compact Camera layout has no horizontal overflow",
|
||||
);
|
||||
const finalWorkflowStep = workflowSteps.at(-1).getBoundingClientRect();
|
||||
assert(
|
||||
finalWorkflowStep.right <= documentObject.documentElement.clientWidth + 1,
|
||||
"all workflow steps remain visible on a compact viewport",
|
||||
);
|
||||
} finally {
|
||||
windowObject.confirm = originalConfirm;
|
||||
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||||
|
||||
@@ -11,6 +11,7 @@ globalThis.assertEqual = (actual, expected, message = "values differ") => {
|
||||
try {
|
||||
await import("/tests/unit/contracts.test.js");
|
||||
await import("/tests/unit/acoustics.test.js");
|
||||
await import("/tests/unit/realtime-localizer.test.js");
|
||||
await import("/tests/unit/builder.test.js");
|
||||
await import("/tests/unit/evaluation.test.js");
|
||||
await import("/tests/unit/camera.test.js");
|
||||
|
||||
@@ -20,6 +20,8 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩
|
||||
- `contracts.test.js`: Array·Scene·Experiment·Result schema, 관계 검증, 안전한 asset 경로와 이동 fixture
|
||||
- `acoustics.test.js`: seed 재현성, 자유음장 전파, trajectory 보간, sample 정렬 시간창과
|
||||
tone/noise/asset 절대 시간 연속성, calibration, STFT와 CPU DAS
|
||||
- `realtime-localizer.test.js`: 기존 CPU DAS와 bit-exact map 동등성, chunk 경계 독립성,
|
||||
bounded ring/result batch, 취소와 geometry·steering cache 연산 절감
|
||||
- `builder.test.js`: Builder scene model, source/asset·trajectory 조작, 오디오 크기와 WAV 사전 검증
|
||||
- `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적
|
||||
beam-spread covariance ellipse
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { computeBinMap, computeDasMap, createDistanceMatrix } from "/packages/acoustics-core/das.js";
|
||||
import { fftInPlace, fftReal } from "/packages/acoustics-core/fft.js";
|
||||
import {
|
||||
buildAcousticPaths,
|
||||
maximumAcousticDelaySeconds,
|
||||
} from "/packages/acoustics-core/environment.js";
|
||||
import { interpolateFrequencyResponse } from "/packages/acoustics-core/frequency-response.js";
|
||||
import {
|
||||
addFrequencyDomainDelay,
|
||||
addFrequencyDomainPaths,
|
||||
createDelayWorkspace,
|
||||
prepareDelaySpectrum,
|
||||
} from "/packages/acoustics-core/fractional-delay.js";
|
||||
@@ -119,6 +125,133 @@ test("거리, dB와 구면 감쇠 기본식을 계산한다", () => {
|
||||
assertNear(sphericalAttenuation(2), 0.5, 1e-12, "1/r attenuation");
|
||||
});
|
||||
|
||||
test("자유음장 경로 계약은 기존 직접음 delay와 1/r gain을 보존한다", () => {
|
||||
const scene = { speedOfSound: 400, seed: 11 };
|
||||
const paths = buildAcousticPaths({
|
||||
scene,
|
||||
sourcePosition: [0, 0, 2],
|
||||
receiverPosition: [0, 0, 0],
|
||||
sourceId: "source",
|
||||
receiverId: "mic",
|
||||
});
|
||||
assertEqual(paths.length, 1, "direct path only");
|
||||
assertEqual(paths[0].kind, "direct", "path kind");
|
||||
assertNear(paths[0].delaySeconds, 0.005, 1e-12, "direct delay");
|
||||
assertNear(paths[0].gain, 0.5, 1e-12, "direct spherical gain");
|
||||
});
|
||||
|
||||
test("shoebox가 1차 반사와 산란을 고정된 sparse path로 만든다", () => {
|
||||
const scene = {
|
||||
speedOfSound: 343,
|
||||
seed: 23,
|
||||
sources: [{ id: "source", position: [0, 0, 1] }],
|
||||
acoustics: {
|
||||
model: "shoebox",
|
||||
room: { min: [-2, -2, -1], max: [2, 2, 3] },
|
||||
reflections: { order: 1, wallGainDb: -6, scattering: 0.25 },
|
||||
diffuseReverb: { enabled: true, rt60Seconds: 0.2, gainDb: -18, tapCount: 4 },
|
||||
},
|
||||
};
|
||||
const options = {
|
||||
scene,
|
||||
sourcePosition: scene.sources[0].position,
|
||||
receiverPosition: [0, 0, 0],
|
||||
sourceId: "source",
|
||||
receiverId: "mic",
|
||||
};
|
||||
const first = buildAcousticPaths(options);
|
||||
const second = buildAcousticPaths(options);
|
||||
assertEqual(first.length, 17, "direct + six reflection + six scatter + four diffuse");
|
||||
assertEqual(first.filter(({ kind }) => kind === "reflection").length, 6, "first-order reflection count");
|
||||
assertEqual(first.filter(({ kind }) => kind === "scattering").length, 6, "scattering count");
|
||||
assertEqual(first.filter(({ kind }) => kind === "diffuse").length, 4, "diffuse count");
|
||||
assertEqual(JSON.stringify(first), JSON.stringify(second), "seeded path set is deterministic");
|
||||
const maximum = maximumAcousticDelaySeconds({
|
||||
scene,
|
||||
arrayDefinition: { microphones: [{ id: "mic", position: [0, 0, 0] }] },
|
||||
});
|
||||
assertNear(maximum, Math.max(...first.map(({ delaySeconds }) => delaySeconds)), 1e-12, "maximum delay");
|
||||
});
|
||||
|
||||
test("차폐물은 직접음을 감쇠하고 더 긴 edge diffraction 경로를 추가한다", () => {
|
||||
const scene = {
|
||||
speedOfSound: 400,
|
||||
seed: 1,
|
||||
acoustics: {
|
||||
model: "shoebox",
|
||||
room: { min: [-2, -2, -2], max: [2, 2, 2] },
|
||||
occluders: [{
|
||||
id: "panel",
|
||||
min: [-0.1, -0.5, -0.5],
|
||||
max: [0.1, 0.5, 0.5],
|
||||
transmissionDb: -20,
|
||||
diffractionGainDb: -12,
|
||||
}],
|
||||
},
|
||||
};
|
||||
const paths = buildAcousticPaths({
|
||||
scene,
|
||||
sourcePosition: [-1, 0, 0],
|
||||
receiverPosition: [1, 0, 0],
|
||||
});
|
||||
assertEqual(paths.length, 2, "direct and diffraction paths");
|
||||
const direct = paths.find(({ kind }) => kind === "direct");
|
||||
const diffraction = paths.find(({ kind }) => kind === "diffraction");
|
||||
assertNear(direct.gain, 0.05, 1e-12, "-20 dB direct transmission");
|
||||
assert(diffraction.distance > direct.distance, "edge detour is longer than line of sight");
|
||||
assert(diffraction.delaySeconds > direct.delaySeconds, "diffraction arrives later");
|
||||
});
|
||||
|
||||
test("마이크 주파수 응답을 dB와 wrapped phase로 보간하고 끝점 밖에서 고정한다", () => {
|
||||
const curve = [
|
||||
{ frequencyHz: 100, gainDb: -6, phaseDeg: 170 },
|
||||
{ frequencyHz: 300, gainDb: 6, phaseDeg: -170 },
|
||||
];
|
||||
const below = interpolateFrequencyResponse(curve, 20);
|
||||
const middle = interpolateFrequencyResponse(curve, 200);
|
||||
const above = interpolateFrequencyResponse(curve, 10_000);
|
||||
assertEqual(below.gainDb, -6, "lower endpoint gain");
|
||||
assertEqual(below.phaseDeg, 170, "lower endpoint phase");
|
||||
assertNear(middle.gainDb, 0, 1e-12, "interpolated dB");
|
||||
assertNear(Math.abs(middle.phaseDeg), 180, 1e-12, "phase follows shortest wrapped path");
|
||||
assertNear(middle.linearGain, 1, 1e-12, "interpolated linear gain");
|
||||
assertEqual(above.gainDb, 6, "upper endpoint gain");
|
||||
assertEqual(above.phaseDeg, -170, "upper endpoint phase");
|
||||
});
|
||||
|
||||
test("DAS 역응답은 깊은 notch의 boost를 기본 24 dB로 제한한다", async () => {
|
||||
const arrayDefinition = {
|
||||
microphones: [{
|
||||
id: "notched",
|
||||
position: [0, 0, 0],
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 0, gainDb: -120 },
|
||||
{ frequencyHz: 4000, gainDb: -120 },
|
||||
],
|
||||
}],
|
||||
};
|
||||
const options = {
|
||||
bin: 8,
|
||||
spectrum: { real: Float32Array.of(1), imaginary: Float32Array.of(0) },
|
||||
frameCount: 1,
|
||||
arrayDefinition,
|
||||
pointCount: 1,
|
||||
distances: Float32Array.of(0),
|
||||
sampleRate: 8000,
|
||||
fftSize: 256,
|
||||
speedOfSound: 343,
|
||||
compensateFrequencyResponse: true,
|
||||
pointBatchSize: 64,
|
||||
};
|
||||
const limited = await computeBinMap(options);
|
||||
const noBoost = await computeBinMap({ ...options, maxCompensationDb: 0 });
|
||||
const explicitLegacyLimit = await computeBinMap({ ...options, maxCompensationDb: 120 });
|
||||
|
||||
assertNear(limited[0], dbToLinear(24) ** 2, 1e-3, "default limited power");
|
||||
assertNear(noBoost[0], 1, 1e-9, "zero boost limit");
|
||||
assert(explicitLegacyLimit[0] > 1e11, "explicit 120 dB limit permits legacy boost");
|
||||
});
|
||||
|
||||
test("index 기반 noise가 seed에 대해 결정적이다", () => {
|
||||
const first = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
|
||||
const second = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
|
||||
@@ -174,6 +307,24 @@ test("FFT phase shift가 integer와 fractional delay를 적용한다", () => {
|
||||
assert(Math.sqrt(squaredError / count) < 1e-3, "fractional delay RMS error");
|
||||
});
|
||||
|
||||
test("여러 음향 경로를 하나의 frequency-domain transfer로 합성한다", () => {
|
||||
const impulse = new Float32Array(16);
|
||||
impulse[2] = 1;
|
||||
const spectrum = prepareDelaySpectrum(impulse, 5);
|
||||
const output = new Float32Array(16);
|
||||
addFrequencyDomainPaths({
|
||||
output,
|
||||
spectrum,
|
||||
paths: [
|
||||
{ delaySamples: 3, gain: 1 },
|
||||
{ delaySamples: 5, gain: 0.5 },
|
||||
],
|
||||
workspace: createDelayWorkspace(spectrum.fftSize),
|
||||
});
|
||||
assertNear(output[5], 1, 1e-6, "direct impulse");
|
||||
assertNear(output[7], 0.5, 1e-6, "reflected impulse");
|
||||
});
|
||||
|
||||
test("STFT frame count가 마지막 partial tail을 포함한다", () => {
|
||||
assertEqual(getFrameCount(512, 512, 256), 1);
|
||||
assertEqual(getFrameCount(513, 512, 256), 2);
|
||||
@@ -207,6 +358,35 @@ test("구면 전파가 거리별 지연과 1/r 진폭비를 적용한다", () =>
|
||||
assertNear(rms(result.channels[0], 16, 150) / rms(result.channels[1], 16, 150), 2, 0.02, "distance amplitude ratio");
|
||||
});
|
||||
|
||||
test("microphone synthesis가 shoebox sparse path를 실제 채널에 적용한다", () => {
|
||||
const scene = {
|
||||
sampleRate: 8000,
|
||||
duration: 0.02,
|
||||
speedOfSound: 343,
|
||||
seed: 7,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
acoustics: {
|
||||
model: "shoebox",
|
||||
room: { min: [-2, -2, -1], max: [2, 2, 3] },
|
||||
reflections: { order: 1, wallGainDb: -12, scattering: 0 },
|
||||
},
|
||||
sources: [{
|
||||
id: "tone", name: "tone", position: [0, 0, 1], gainDb: -30,
|
||||
startTime: 0, duration: 0.02,
|
||||
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
|
||||
}],
|
||||
ambient: [],
|
||||
};
|
||||
const result = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition: { microphones: [{ id: "mic", position: [0, 0, 0] }] },
|
||||
});
|
||||
assertEqual(result.acousticModel, "shoebox", "reported acoustic model");
|
||||
assertEqual(result.acousticPathCount, 7, "one direct and six image-source paths");
|
||||
assertEqual(result.acousticPathCounts.reflection, 6, "reflection path count");
|
||||
assert(result.preNormalizationPeak > 0, "multipath channel contains signal");
|
||||
});
|
||||
|
||||
test("peak mix 정규화와 channel buffer 한도를 적용한다", () => {
|
||||
const scene = {
|
||||
sampleRate: 8000,
|
||||
@@ -251,11 +431,65 @@ test("ambient에도 microphone gain과 phase calibration 편차를 적용한다"
|
||||
assertNear(result.channels[0][80], expected, 4e-3, "ambient calibrated sample");
|
||||
});
|
||||
|
||||
test("합성 microphone channel에 주파수별 응답 gain을 적용한다", () => {
|
||||
const scene = {
|
||||
sampleRate: 8000,
|
||||
duration: 0.256,
|
||||
speedOfSound: 343,
|
||||
seed: 1,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [
|
||||
{
|
||||
id: "lower", name: "lower", position: [0, 0, 0], gainDb: -30, startTime: 0, duration: 0.256,
|
||||
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
|
||||
},
|
||||
{
|
||||
id: "upper", name: "upper", position: [0, 0, 0], gainDb: -30, startTime: 0, duration: 0.256,
|
||||
signal: { type: "tone", frequency: 1000, phaseDeg: 0 },
|
||||
},
|
||||
],
|
||||
ambient: [],
|
||||
};
|
||||
const baselineArray = { microphones: [{ id: "mic", position: [0, 0, 0] }] };
|
||||
const responseArray = {
|
||||
microphones: [{
|
||||
id: "mic",
|
||||
position: [0, 0, 0],
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 500, gainDb: -6.020599913 },
|
||||
{ frequencyHz: 1000, gainDb: 6.020599913 },
|
||||
],
|
||||
}],
|
||||
};
|
||||
const baseline = fftReal(synthesizeMicrophoneSignals({ scene, arrayDefinition: baselineArray }).channels[0]);
|
||||
const filtered = fftReal(synthesizeMicrophoneSignals({ scene, arrayDefinition: responseArray }).channels[0]);
|
||||
const magnitudeAt = (spectrum, frequency) => {
|
||||
const bin = Math.round((frequency * spectrum.real.length) / scene.sampleRate);
|
||||
return Math.hypot(spectrum.real[bin], spectrum.imaginary[bin]);
|
||||
};
|
||||
assertNear(
|
||||
magnitudeAt(filtered, 500) / magnitudeAt(baseline, 500),
|
||||
0.5,
|
||||
0.015,
|
||||
"500 Hz channel response",
|
||||
);
|
||||
assertNear(
|
||||
magnitudeAt(filtered, 1000) / magnitudeAt(baseline, 1000),
|
||||
2,
|
||||
0.03,
|
||||
"1 kHz channel response",
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
microphone.frequencyResponse = [
|
||||
{ frequencyHz: 0, gainDb: (index % 4) * 6 - 9, phaseDeg: (index % 8) * 30 - 105 },
|
||||
{ frequencyHz: 4000, gainDb: (index % 4) * 6 - 9, phaseDeg: (index % 8) * 30 - 105 },
|
||||
];
|
||||
});
|
||||
const scene = {
|
||||
id: "das-scene",
|
||||
@@ -292,6 +526,21 @@ test("CPU DAS peak가 calibration 후 단일 tone의 실제 grid 위치를 찾
|
||||
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");
|
||||
|
||||
const uncompensated = await computeDasMap({
|
||||
channels: synthesis.channels,
|
||||
sampleRate: synthesis.sampleRate,
|
||||
arrayDefinition,
|
||||
scene,
|
||||
experiment: {
|
||||
...experiment,
|
||||
beamforming: { ...experiment.beamforming, compensateFrequencyResponse: false },
|
||||
},
|
||||
});
|
||||
assert(
|
||||
result.bands[0].peak.rawPower > uncompensated.bands[0].peak.rawPower * 1.5,
|
||||
"known response compensation improves coherent peak power",
|
||||
);
|
||||
});
|
||||
|
||||
test("독립 복소 spectrum fixture에서 DAS steering 부호가 맞다", async () => {
|
||||
@@ -329,6 +578,21 @@ test("독립 복소 spectrum fixture에서 DAS steering 부호가 맞다", async
|
||||
});
|
||||
assertNear(map[1], 1, 1e-6, "target coherent power");
|
||||
assert(map[1] > map[0] * 1.1, `wrong-sign alternative power=${map[0]}`);
|
||||
const legacyMap = await computeBinMap({
|
||||
bin,
|
||||
spectrum,
|
||||
frameCount: 1,
|
||||
arrayDefinition,
|
||||
pointCount: 2,
|
||||
distances: createDistanceMatrix(arrayDefinition, scan),
|
||||
sampleRate,
|
||||
fftSize,
|
||||
speedOfSound,
|
||||
pointBatchSize: 64,
|
||||
compensateFrequencyResponse: false,
|
||||
});
|
||||
assertEqual(legacyMap[0], map[0], "response-free map remains unchanged when compensation is disabled");
|
||||
assertEqual(legacyMap[1], map[1], "response-free target power remains unchanged");
|
||||
});
|
||||
|
||||
test("CPU DAS가 event-loop 취소와 working-set 한도를 지킨다", async () => {
|
||||
|
||||
@@ -4,6 +4,12 @@ import {
|
||||
sceneHasTrajectories,
|
||||
scenePlaybackState,
|
||||
} from "/apps/acoustic-camera/timeline.js";
|
||||
import {
|
||||
applyAcousticProfile,
|
||||
detectAcousticProfile,
|
||||
inferShoeboxRoom,
|
||||
maximumSparsePathsPerReceiver,
|
||||
} from "/apps/acoustic-camera/acoustic-profiles.js";
|
||||
|
||||
import {
|
||||
alignExperimentToScene,
|
||||
@@ -246,6 +252,41 @@ test("Camera workload counts unique overlapping FFT bins", () => {
|
||||
assertNear(workload.distanceMiB, (80 * 3 * 4) / 1024 ** 2, 1e-15, "distance table size");
|
||||
});
|
||||
|
||||
test("Camera acoustic presets bound current geometry and expose predictable sparse cost", () => {
|
||||
const scene = {
|
||||
sources: [{
|
||||
id: "moving",
|
||||
position: [0, 0, 2],
|
||||
trajectory: [
|
||||
{ time: 0, position: [-0.5, 0, 2] },
|
||||
{ time: 1, position: [0.75, 0.5, 3] },
|
||||
],
|
||||
}],
|
||||
};
|
||||
const arrayDefinition = {
|
||||
microphones: [
|
||||
{ id: "left", position: [-0.2, 0, 0] },
|
||||
{ id: "right", position: [0.2, 0, 0] },
|
||||
],
|
||||
};
|
||||
const experiment = {
|
||||
scan: { xMin: -1, xMax: 1, yMin: -0.75, yMax: 0.75, z: 2.5 },
|
||||
};
|
||||
const room = inferShoeboxRoom(scene, arrayDefinition, experiment);
|
||||
assert(room.min[0] < -1 && room.max[0] > 1, "scan x is padded");
|
||||
assert(room.min[2] < 0 && room.max[2] > 3, "array and trajectory depth are padded");
|
||||
|
||||
applyAcousticProfile(scene, arrayDefinition, experiment, "typical-room");
|
||||
assertEqual(detectAcousticProfile(scene), "typical-room", "profile round trip");
|
||||
assertEqual(maximumSparsePathsPerReceiver(scene), 19, "bounded typical-room path count");
|
||||
assertEqual(scene.acoustics.room.min.join(","), room.min.join(","), "inferred room minimum");
|
||||
assertEqual(scene.acoustics.room.max.join(","), room.max.join(","), "inferred room maximum");
|
||||
|
||||
applyAcousticProfile(scene, arrayDefinition, experiment, "free-field");
|
||||
assertEqual(detectAcousticProfile(scene), "free-field", "free-field selection");
|
||||
assertEqual(maximumSparsePathsPerReceiver(scene), 1, "free-field path count");
|
||||
});
|
||||
|
||||
test("Camera distance shift keeps the scene and scan on one z plane", () => {
|
||||
const scene = {
|
||||
sources: [
|
||||
@@ -260,6 +301,17 @@ test("Camera distance shift keeps the scene and scan on one z plane", () => {
|
||||
{ id: "two", position: [-0.3, 0.4, 2.5] },
|
||||
],
|
||||
image: { plane: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1 } },
|
||||
acoustics: {
|
||||
model: "shoebox",
|
||||
room: { min: [-2, -2, -0.5], max: [2, 2, 4] },
|
||||
occluders: [{
|
||||
id: "screen",
|
||||
min: [-0.2, -0.2, 1],
|
||||
max: [0.2, 0.2, 1.1],
|
||||
transmissionDb: -20,
|
||||
diffractionGainDb: -12,
|
||||
}],
|
||||
},
|
||||
};
|
||||
const experiment = { scan: { z: 2 } };
|
||||
|
||||
@@ -279,6 +331,10 @@ test("Camera distance shift keeps the scene and scan on one z plane", () => {
|
||||
);
|
||||
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");
|
||||
assertEqual(scene.acoustics.room.min[2], 0.75, "room minimum z shift");
|
||||
assertEqual(scene.acoustics.room.max[2], 5.25, "room maximum z shift");
|
||||
assertEqual(scene.acoustics.occluders[0].min[2], 2.25, "occluder minimum z shift");
|
||||
assertEqual(scene.acoustics.occluders[0].max[2], 2.35, "occluder maximum z shift");
|
||||
});
|
||||
|
||||
test("Camera aligns an experiment to an imported scene without changing grid resolution", () => {
|
||||
@@ -364,7 +420,14 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
};
|
||||
const run = {
|
||||
backend: "cpu",
|
||||
signalStats: { preNormalizationPeak: 0.75, appliedScale: 1, clippedSampleCount: 0 },
|
||||
signalStats: {
|
||||
preNormalizationPeak: 0.75,
|
||||
appliedScale: 1,
|
||||
clippedSampleCount: 0,
|
||||
acousticModel: "shoebox",
|
||||
acousticPathCount: 38,
|
||||
acousticPathCounts: { direct: 2, reflection: 12, scattering: 12, diffuse: 12 },
|
||||
},
|
||||
timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 },
|
||||
fallback: { code: "webgpu-unavailable", message: "CPU fallback" },
|
||||
bands: [{
|
||||
@@ -445,6 +508,9 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
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");
|
||||
assertEqual(result.signalStats.acousticModel, "shoebox", "acoustic model is exported");
|
||||
assertEqual(result.signalStats.acousticPathCount, 38, "acoustic path count is exported");
|
||||
assertEqual(result.signalStats.acousticPathCounts.diffuse, 12, "path kinds are exported");
|
||||
|
||||
const issues = await validateResult(result, { experiment, scene });
|
||||
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
|
||||
|
||||
@@ -61,6 +61,43 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
|
||||
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
|
||||
});
|
||||
|
||||
test("shoebox 음향 환경의 room과 occluder 경계를 검증한다", async () => {
|
||||
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
|
||||
scene.acoustics = {
|
||||
model: "shoebox",
|
||||
room: { min: [-2, -2, -1], max: [2, 2, 3] },
|
||||
reflections: { order: 1, wallGainDb: -8, scattering: 0.2 },
|
||||
diffuseReverb: { enabled: true, rt60Seconds: 0.35, gainDb: -20, tapCount: 12 },
|
||||
occluders: [{
|
||||
id: "screen",
|
||||
min: [-0.2, -0.2, 0.8],
|
||||
max: [0.2, 0.2, 1],
|
||||
transmissionDb: -18,
|
||||
diffractionGainDb: -12,
|
||||
}],
|
||||
};
|
||||
assertEqual((await validateScene(scene)).length, 0, "valid shoebox scene");
|
||||
|
||||
scene.acoustics.room.max[0] = scene.acoustics.room.min[0];
|
||||
let issues = await validateScene(scene);
|
||||
assert(issues.some(({ path }) => path === "$.acoustics.room"), "unordered room bounds");
|
||||
|
||||
scene.acoustics.room.max[0] = 2;
|
||||
scene.acoustics.occluders[0].max[2] = 4;
|
||||
issues = await validateScene(scene);
|
||||
assert(
|
||||
issues.some(({ path, message }) => path.includes("occluders[0]") && message.includes("room")),
|
||||
"occluder outside room",
|
||||
);
|
||||
|
||||
scene.acoustics = {
|
||||
model: "free-field",
|
||||
room: { min: [-2, -2, -1], max: [2, 2, 3] },
|
||||
};
|
||||
issues = await validateScene(scene);
|
||||
assert(issues.some(({ path }) => path === "$.acoustics"), "unused free-field settings");
|
||||
});
|
||||
|
||||
test("Experiment 피크 검출 임계값 비율은 재현 가능하고 유효한 범위여야 한다", async () => {
|
||||
const [arrayDefinition, scene, experiment] = await Promise.all([
|
||||
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
|
||||
@@ -125,6 +162,32 @@ test("중복 마이크 id를 거부한다", async () => {
|
||||
assert((await validateArrayDefinition(arrayDefinition)).some(({ message }) => message.includes("중복")));
|
||||
});
|
||||
|
||||
test("마이크 주파수 응답점은 안전한 범위에서 주파수 오름차순이어야 한다", async () => {
|
||||
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
|
||||
arrayDefinition.microphones[0].frequencyResponse = [
|
||||
{ frequencyHz: 20, gainDb: -3 },
|
||||
{ frequencyHz: 1000, gainDb: 1.5, phaseDeg: 12 },
|
||||
{ frequencyHz: 20_000, gainDb: -2 },
|
||||
];
|
||||
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "valid response curve");
|
||||
|
||||
arrayDefinition.microphones[0].frequencyResponse[2].frequencyHz = 1000;
|
||||
let issues = await validateArrayDefinition(arrayDefinition);
|
||||
assert(
|
||||
issues.some(({ path, message }) =>
|
||||
path === "$.microphones[0].frequencyResponse[2].frequencyHz" &&
|
||||
message.includes("엄격히 증가")),
|
||||
"duplicate frequency is rejected with sorting guidance",
|
||||
);
|
||||
|
||||
arrayDefinition.microphones[0].frequencyResponse[2].frequencyHz = 1_000_001;
|
||||
issues = await validateArrayDefinition(arrayDefinition);
|
||||
assert(
|
||||
issues.some(({ path }) => path === "$.microphones[0].frequencyResponse[2].frequencyHz"),
|
||||
"globally bounded frequency is enforced without a Scene Nyquist dependency",
|
||||
);
|
||||
});
|
||||
|
||||
test("Nyquist를 넘는 tone을 거부한다", async () => {
|
||||
const scene = {
|
||||
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
|
||||
@@ -156,11 +219,15 @@ test("JSON Schema의 누락 필드와 범위·알 수 없는 필드를 거부한
|
||||
scene.unexpected = true;
|
||||
delete experiment.beamforming.normalization;
|
||||
experiment.beamforming.dynamicRangeDb = 4;
|
||||
experiment.beamforming.compensateFrequencyResponse = "false";
|
||||
experiment.beamforming.maxCompensationDb = 121;
|
||||
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")));
|
||||
assert(experimentIssues.some(({ path }) => path.endsWith("compensateFrequencyResponse")));
|
||||
assert(experimentIssues.some(({ path }) => path.endsWith("maxCompensationDb")));
|
||||
});
|
||||
|
||||
test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => {
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
import { computeDasMap } from "/packages/acoustics-core/das.js";
|
||||
import {
|
||||
createLocalizationPlan,
|
||||
createStreamingLocalizer,
|
||||
detectBandPeaks,
|
||||
localizeMultichannel,
|
||||
} from "/packages/acoustics-core/realtime-localizer.js";
|
||||
import { distance3 } from "/packages/acoustics-core/math.js";
|
||||
|
||||
function createFixture(sampleCount = 320) {
|
||||
const sampleRate = 4096;
|
||||
const speedOfSound = 343;
|
||||
const frequency = 512;
|
||||
const target = [0.1, -0.1, 1];
|
||||
const array = {
|
||||
id: "portable-array",
|
||||
microphones: [
|
||||
{
|
||||
id: "nw",
|
||||
position: [-0.12, 0.12, 0],
|
||||
sensitivityDb: 1.5,
|
||||
phaseOffsetDeg: -4,
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 400, gainDb: -1, phaseDeg: -3 },
|
||||
{ frequencyHz: 600, gainDb: 2, phaseDeg: 5 },
|
||||
],
|
||||
},
|
||||
{ id: "ne", position: [0.12, 0.12, 0], sensitivityDb: -0.5, phaseOffsetDeg: 2 },
|
||||
{ id: "sw", position: [-0.12, -0.12, 0] },
|
||||
{ id: "se", position: [0.12, -0.12, 0] },
|
||||
],
|
||||
};
|
||||
const channels = array.microphones.map((microphone) => {
|
||||
const delay = distance3(target, microphone.position) / speedOfSound;
|
||||
return Float32Array.from(
|
||||
{ length: sampleCount },
|
||||
(_, sampleIndex) =>
|
||||
Math.sin(2 * Math.PI * frequency * (sampleIndex / sampleRate - delay)),
|
||||
);
|
||||
});
|
||||
const scan = {
|
||||
z: 1,
|
||||
xMin: -0.2,
|
||||
xMax: 0.2,
|
||||
yMin: -0.1,
|
||||
yMax: 0.1,
|
||||
columns: 5,
|
||||
rows: 3,
|
||||
};
|
||||
const bands = [{ id: "tone", minHz: 500, maxHz: 524, color: "#65f2bf" }];
|
||||
const config = {
|
||||
sampleRate,
|
||||
array,
|
||||
scan,
|
||||
bands,
|
||||
speedOfSound,
|
||||
stft: { fftSize: 64, hopSize: 32 },
|
||||
normalization: "peak",
|
||||
maximumSteeringCacheBytes: 1024 * 1024,
|
||||
computeOptions: {
|
||||
pointBatchSize: 1024,
|
||||
stftTransformBatchSize: 1024,
|
||||
},
|
||||
};
|
||||
return { sampleRate, speedOfSound, target, array, channels, scan, bands, config };
|
||||
}
|
||||
|
||||
function assertFloat32Exact(actual, expected, label) {
|
||||
assertEqual(actual.length, expected.length, `${label} length`);
|
||||
for (let index = 0; index < actual.length; index += 1) {
|
||||
assertEqual(actual[index], expected[index], `${label}[${index}]`);
|
||||
}
|
||||
}
|
||||
|
||||
function sliceChannels(channels, start, end) {
|
||||
return channels.map((channel) => channel.slice(start, end));
|
||||
}
|
||||
|
||||
test("portable localizer가 Scene 합성 없이 기존 CPU DAS와 bit-exact map을 만든다", async () => {
|
||||
const fixture = createFixture(128);
|
||||
const experiment = {
|
||||
stft: { fftSize: 64, hopSize: 32, window: "hann" },
|
||||
scan: fixture.scan,
|
||||
frequencyBands: fixture.bands,
|
||||
beamforming: { normalization: "peak", compensateFrequencyResponse: true },
|
||||
};
|
||||
const baseline = await computeDasMap({
|
||||
channels: fixture.channels,
|
||||
sampleRate: fixture.sampleRate,
|
||||
arrayDefinition: fixture.array,
|
||||
scene: { speedOfSound: fixture.speedOfSound },
|
||||
experiment,
|
||||
pointBatchSize: 1024,
|
||||
stftTransformBatchSize: 1024,
|
||||
});
|
||||
const plan = createLocalizationPlan(fixture.config);
|
||||
const portable = await localizeMultichannel({
|
||||
plan,
|
||||
channels: fixture.channels,
|
||||
});
|
||||
|
||||
assertFloat32Exact(portable.bands[0].rawMap, baseline.bands[0].rawMap, "raw map");
|
||||
assertFloat32Exact(portable.bands[0].displayMap, baseline.bands[0].displayMap, "display map");
|
||||
assertEqual(portable.bands[0].peak.index, baseline.bands[0].peak.index, "peak index");
|
||||
assertEqual(portable.locations[0].bandId, "tone", "portable location band");
|
||||
assertEqual(portable.locations[0].rank, 1, "strongest portable location rank");
|
||||
assertEqual(portable.sampleCount, 128, "sample count");
|
||||
assertEqual(portable.duration, 128 / fixture.sampleRate, "block duration");
|
||||
assert(
|
||||
Number.isFinite(portable.processingTimeMs) && portable.processingTimeMs >= 0,
|
||||
"block processing time telemetry",
|
||||
);
|
||||
});
|
||||
|
||||
test("portable raw-map peak detection이 같은 band의 두 진원 후보를 강도순으로 보존한다", () => {
|
||||
const scan = {
|
||||
z: 1,
|
||||
xMin: 0,
|
||||
xMax: 4,
|
||||
yMin: 0,
|
||||
yMax: 4,
|
||||
columns: 5,
|
||||
rows: 5,
|
||||
};
|
||||
const rawMap = new Float32Array(25);
|
||||
rawMap[6] = 1;
|
||||
rawMap[18] = 0.75;
|
||||
rawMap[3] = 0.49;
|
||||
|
||||
const peaks = detectBandPeaks(rawMap, scan);
|
||||
assertEqual(peaks.length, 2, "two peaks above default threshold");
|
||||
assertEqual(peaks[0].index, 6, "strongest peak first");
|
||||
assertEqual(peaks[0].rank, 1, "strongest rank");
|
||||
assertEqual(peaks[1].index, 18, "second source peak retained");
|
||||
assertEqual(peaks[1].rank, 2, "second rank");
|
||||
assert(Math.abs(peaks[1].rawPower - 0.75) <= 1e-7, "second raw power");
|
||||
assertEqual(
|
||||
detectBandPeaks(rawMap, scan, { maximumDetections: 1 }).length,
|
||||
1,
|
||||
"maximum detection bound",
|
||||
);
|
||||
});
|
||||
|
||||
test("CPU 분석 계획이 반복 geometry·steering 연산과 bin map 할당을 실제로 제거한다", async () => {
|
||||
const fixture = createFixture(128);
|
||||
const plan = createLocalizationPlan(fixture.config);
|
||||
const first = await localizeMultichannel({ plan, channels: fixture.channels });
|
||||
const second = await localizeMultichannel({ plan, channels: fixture.channels });
|
||||
const cache = second.analysisCache;
|
||||
const pointMicrophonePairs = fixture.scan.columns * fixture.scan.rows * fixture.array.microphones.length;
|
||||
|
||||
assertEqual(cache.analysisAttempts, 2, "analysis attempts");
|
||||
assertEqual(cache.completedAnalyses, 2, "completed analyses");
|
||||
assertEqual(cache.distanceMatrixBuilds, 1, "one retained distance matrix");
|
||||
assertEqual(cache.distanceEvaluations, pointMicrophonePairs, "distance evaluations performed");
|
||||
assertEqual(
|
||||
cache.avoidedDistanceEvaluations,
|
||||
pointMicrophonePairs,
|
||||
"second distance pass avoided",
|
||||
);
|
||||
assert(cache.steeringCacheEnabled, "bounded steering cache enabled");
|
||||
assertEqual(cache.steeringMatrixBuilds, 1, "one steering matrix build");
|
||||
assertEqual(cache.steeringCacheHits, 1, "second steering pass is a cache hit");
|
||||
assertEqual(
|
||||
cache.avoidedSteeringPointMicrophonePairs,
|
||||
pointMicrophonePairs,
|
||||
"second trigonometric steering pass avoided",
|
||||
);
|
||||
assertEqual(cache.avoidedBinMapAllocations, 1, "second temporary bin map allocation avoided");
|
||||
assert(cache.retainedBytes > 0, "retained cache bytes are reported");
|
||||
assertFloat32Exact(second.bands[0].rawMap, first.bands[0].rawMap, "cached repeat raw map");
|
||||
});
|
||||
|
||||
test("wide-band 분석 계획이 예산만큼 steering bin과 STFT workspace를 재사용한다", async () => {
|
||||
const fixture = createFixture(128);
|
||||
const pointMicrophonePairs =
|
||||
fixture.scan.columns * fixture.scan.rows * fixture.array.microphones.length;
|
||||
const steeringBytesPerBin =
|
||||
pointMicrophonePairs * Float64Array.BYTES_PER_ELEMENT * 2;
|
||||
const wideBands = [{ id: "wide", minHz: 500, maxHz: 580, color: "#65f2bf" }];
|
||||
const partialPlan = createLocalizationPlan({
|
||||
...fixture.config,
|
||||
bands: wideBands,
|
||||
maximumSteeringCacheBytes: steeringBytesPerBin,
|
||||
});
|
||||
const uncachedPlan = createLocalizationPlan({
|
||||
...fixture.config,
|
||||
bands: wideBands,
|
||||
maximumSteeringCacheBytes: 0,
|
||||
});
|
||||
|
||||
const first = await localizeMultichannel({ plan: partialPlan, channels: fixture.channels });
|
||||
const second = await localizeMultichannel({ plan: partialPlan, channels: fixture.channels });
|
||||
const uncached = await localizeMultichannel({ plan: uncachedPlan, channels: fixture.channels });
|
||||
const cache = second.analysisCache;
|
||||
|
||||
assertFloat32Exact(first.bands[0].rawMap, uncached.bands[0].rawMap, "partial cache raw map");
|
||||
assertFloat32Exact(second.bands[0].rawMap, first.bands[0].rawMap, "partial repeat raw map");
|
||||
assertEqual(cache.steeringCacheBinCapacity, 1, "one Float64 steering bin retained");
|
||||
assertEqual(cache.steeringCachedBinCount, 1, "one steering matrix built");
|
||||
assertEqual(cache.steeringUncachedBinCount, 1, "one selected bin remains uncached");
|
||||
assertEqual(cache.steeringCacheBytes, steeringBytesPerBin, "steering byte budget used exactly");
|
||||
assertEqual(cache.requestedSteeringCacheBytes, steeringBytesPerBin * 2, "full request reported");
|
||||
assertEqual(cache.steeringMatrixBuilds, 1, "cached bin built once");
|
||||
assertEqual(cache.steeringCacheHits, 1, "cached bin reused once");
|
||||
assertEqual(cache.uncachedSteeringUses, 2, "uncached bin computed once per analysis");
|
||||
assertEqual(
|
||||
cache.uncachedSteeringPointMicrophonePairs,
|
||||
pointMicrophonePairs * 2,
|
||||
"uncached steering operations counted",
|
||||
);
|
||||
assertEqual(cache.stftWindowBuilds, 1, "Hann window built once");
|
||||
assertEqual(cache.stftWorkspaceAllocations, 1, "FFT workspace allocated once");
|
||||
assertEqual(cache.stftWorkspaceUses, 2, "workspace used by both analyses");
|
||||
assertEqual(cache.avoidedStftWindowBuilds, 1, "second Hann build avoided");
|
||||
assertEqual(cache.avoidedStftWorkspaceAllocations, 1, "second FFT allocation avoided");
|
||||
assertEqual(cache.stftTransforms, 24, "STFT transform operation count");
|
||||
assertEqual(cache.fftButterflies, 4608, "FFT butterfly operation count");
|
||||
assertEqual(cache.beamformingComplexMacs, 720, "beamforming operation count");
|
||||
assertEqual(cache.binMapAllocations, 1, "one reusable bin map allocation");
|
||||
assertEqual(cache.steeringWorkspaceAllocations, 1, "one reusable steering workspace");
|
||||
assert(cache.retainedBytes <= partialPlan.dasPlan.maximumPlanBytes, "plan hard cap retained");
|
||||
});
|
||||
|
||||
test("portable plan은 취소 응답성과 처리량을 균형 잡은 기본 batch를 사용한다", () => {
|
||||
const fixture = createFixture(128);
|
||||
const config = { ...fixture.config };
|
||||
delete config.computeOptions;
|
||||
const defaults = createLocalizationPlan(config);
|
||||
assertEqual(defaults.computeOptions.pointBatchSize, 256, "default point batch");
|
||||
assertEqual(defaults.computeOptions.stftTransformBatchSize, 128, "default STFT batch");
|
||||
assertEqual(defaults.maxCompensationDb, 24, "default inverse-response boost limit");
|
||||
assertEqual(defaults.peakDetection.thresholdRatio, 0.5, "default peak threshold");
|
||||
assertEqual(defaults.peakDetection.maximumDetections, 8, "default peak count bound");
|
||||
|
||||
const custom = createLocalizationPlan({
|
||||
...config,
|
||||
computeOptions: { pointBatchSize: 7, stftTransformBatchSize: 9 },
|
||||
});
|
||||
assertEqual(custom.computeOptions.pointBatchSize, 7, "point batch override");
|
||||
assertEqual(custom.computeOptions.stftTransformBatchSize, 9, "STFT batch override");
|
||||
|
||||
const calibrated = createLocalizationPlan({
|
||||
...config,
|
||||
maxCompensationDb: 0,
|
||||
peakDetection: {
|
||||
thresholdRatio: 0.25,
|
||||
minimumPeakDistanceM: 0,
|
||||
maximumDetections: 3,
|
||||
},
|
||||
});
|
||||
assertEqual(calibrated.maxCompensationDb, 0, "portable compensation alias");
|
||||
assertEqual(calibrated.peakDetection.thresholdRatio, 0.25, "custom peak threshold");
|
||||
assertEqual(calibrated.peakDetection.minimumPeakDistanceM, 0, "custom peak spacing");
|
||||
assertEqual(calibrated.peakDetection.maximumDetections, 3, "custom peak count");
|
||||
|
||||
for (const invalidConfig of [
|
||||
{ ...config, maxCompensationDb: 121 },
|
||||
{ ...config, peakDetection: { thresholdRatio: -0.1 } },
|
||||
{ ...config, peakDetection: { maximumDetections: 129 } },
|
||||
]) {
|
||||
let caught;
|
||||
try {
|
||||
createLocalizationPlan(invalidConfig);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
assert(caught instanceof RangeError, "invalid portable localization option rejected");
|
||||
}
|
||||
});
|
||||
|
||||
test("streaming localizer 결과가 임의 chunk 경계와 무관하다", async () => {
|
||||
const fixture = createFixture(320);
|
||||
const streamConfig = {
|
||||
...fixture.config,
|
||||
analysisWindowSamples: 128,
|
||||
analysisHopSamples: 64,
|
||||
maximumBufferedSamples: 512,
|
||||
maximumResultsPerCall: 4,
|
||||
};
|
||||
const whole = createStreamingLocalizer(streamConfig);
|
||||
whole.append(fixture.channels);
|
||||
const wholeResults = await whole.analyzeAvailable({ maximumResults: 4 });
|
||||
|
||||
const chunked = createStreamingLocalizer(streamConfig);
|
||||
const chunkSizes = [17, 83, 1, 91, 128];
|
||||
const chunkedResults = [];
|
||||
let offset = 0;
|
||||
for (const chunkSize of chunkSizes) {
|
||||
const end = offset + chunkSize;
|
||||
chunkedResults.push(...await chunked.push(sliceChannels(fixture.channels, offset, end)));
|
||||
offset = end;
|
||||
}
|
||||
|
||||
assertEqual(wholeResults.length, 4, "whole block window count");
|
||||
assertEqual(chunkedResults.length, wholeResults.length, "chunked window count");
|
||||
wholeResults.forEach((expected, index) => {
|
||||
const actual = chunkedResults[index];
|
||||
assertEqual(actual.startSample, index * 64, `chunked start sample ${index}`);
|
||||
assertEqual(actual.endSample, index * 64 + 128, `chunked end sample ${index}`);
|
||||
assertEqual(actual.sequence, index, `chunked sequence ${index}`);
|
||||
assertFloat32Exact(actual.bands[0].rawMap, expected.bands[0].rawMap, `chunked map ${index}`);
|
||||
});
|
||||
assertEqual(chunked.getState().droppedSamples, 0, "no samples dropped under capacity");
|
||||
});
|
||||
|
||||
test("streaming ring buffer와 호출별 결과 수가 설정된 상한을 지킨다", async () => {
|
||||
const fixture = createFixture(400);
|
||||
let planLimitError;
|
||||
try {
|
||||
createLocalizationPlan({ ...fixture.config, maximumPlanBytes: 64 });
|
||||
} catch (error) {
|
||||
planLimitError = error;
|
||||
}
|
||||
assert(
|
||||
planLimitError instanceof RangeError &&
|
||||
planLimitError.message.includes("maximumPlanBytes"),
|
||||
"analysis plan allocation hard limit",
|
||||
);
|
||||
let calibrationLimitError;
|
||||
try {
|
||||
createLocalizationPlan({
|
||||
...fixture.config,
|
||||
maximumSteeringCacheBytes: 0,
|
||||
maximumPlanBytes: 1963,
|
||||
});
|
||||
} catch (error) {
|
||||
calibrationLimitError = error;
|
||||
}
|
||||
assert(
|
||||
calibrationLimitError instanceof RangeError &&
|
||||
calibrationLimitError.message.includes("calibration bytes"),
|
||||
"all calibration arrays are reserved by the plan hard limit",
|
||||
);
|
||||
const exactMemoryPlan = createLocalizationPlan({
|
||||
...fixture.config,
|
||||
maximumSteeringCacheBytes: 0,
|
||||
maximumPlanBytes: 1964,
|
||||
});
|
||||
const exactMemoryResult = await localizeMultichannel({
|
||||
plan: exactMemoryPlan,
|
||||
channels: fixture.channels,
|
||||
});
|
||||
assertEqual(exactMemoryPlan.dasPlan.calibrationBytes, 64, "calibration byte reservation");
|
||||
assertEqual(exactMemoryResult.analysisCache.retainedBytes, 1964, "exact retained plan bytes");
|
||||
const invalidResponseConfig = structuredClone(fixture.config);
|
||||
invalidResponseConfig.array.microphones[0].frequencyResponse = [
|
||||
{ frequencyHz: 1000, gainDb: 0 },
|
||||
{ frequencyHz: 1000, gainDb: -3 },
|
||||
];
|
||||
let responseError;
|
||||
try {
|
||||
createLocalizationPlan(invalidResponseConfig);
|
||||
} catch (error) {
|
||||
responseError = error;
|
||||
}
|
||||
assert(
|
||||
responseError instanceof RangeError && responseError.message.includes("strictly increasing"),
|
||||
"portable microphone response validation",
|
||||
);
|
||||
let inputLimitError;
|
||||
try {
|
||||
createStreamingLocalizer({
|
||||
...fixture.config,
|
||||
analysisWindowSamples: 128,
|
||||
maximumBufferedSamples: 128,
|
||||
maximumInputBufferBytes: 1024,
|
||||
});
|
||||
} catch (error) {
|
||||
inputLimitError = error;
|
||||
}
|
||||
assert(
|
||||
inputLimitError instanceof RangeError &&
|
||||
inputLimitError.message.includes("maximumInputBufferBytes"),
|
||||
"input allocation hard limit",
|
||||
);
|
||||
const bounded = createStreamingLocalizer({
|
||||
...fixture.config,
|
||||
analysisWindowSamples: 128,
|
||||
analysisHopSamples: 64,
|
||||
maximumBufferedSamples: 128,
|
||||
maximumResultsPerCall: 1,
|
||||
});
|
||||
const appended = bounded.append(fixture.channels);
|
||||
assertEqual(appended.bufferedSamples, 128, "ring capacity");
|
||||
assertEqual(appended.droppedSamples, 272, "oldest samples dropped");
|
||||
assertEqual(appended.bufferStartSample, 272, "retained absolute sample origin");
|
||||
assertEqual(
|
||||
appended.retainedInputBytes,
|
||||
fixture.array.microphones.length * (128 + 128) * Float32Array.BYTES_PER_ELEMENT,
|
||||
"fixed ring and analysis workspace bytes",
|
||||
);
|
||||
const boundedResults = await bounded.analyzeAvailable();
|
||||
assertEqual(boundedResults.length, 1, "one bounded result");
|
||||
assertEqual(boundedResults[0].startSample, 272, "latest retained window analyzed");
|
||||
assertEqual(boundedResults[0].deadlineMs, 64 / fixture.sampleRate * 1000, "hop deadline");
|
||||
assertEqual(typeof boundedResults[0].deadlineMet, "boolean", "deadline verdict");
|
||||
const boundedState = bounded.getState();
|
||||
assertEqual(
|
||||
boundedState.lastProcessingTimeMs,
|
||||
boundedResults[0].processingTimeMs,
|
||||
"last processing time telemetry",
|
||||
);
|
||||
assert(
|
||||
boundedState.maximumProcessingTimeMs >= boundedState.lastProcessingTimeMs,
|
||||
"maximum processing time telemetry",
|
||||
);
|
||||
assertEqual(
|
||||
boundedState.deadlineMisses,
|
||||
boundedResults[0].deadlineMet ? 0 : 1,
|
||||
"deadline miss telemetry",
|
||||
);
|
||||
|
||||
const resultBound = createStreamingLocalizer({
|
||||
...fixture.config,
|
||||
analysisWindowSamples: 128,
|
||||
analysisHopSamples: 64,
|
||||
maximumBufferedSamples: 512,
|
||||
maximumResultsPerCall: 2,
|
||||
});
|
||||
resultBound.append(fixture.channels);
|
||||
const first = await resultBound.analyzeAvailable();
|
||||
const second = await resultBound.analyzeAvailable();
|
||||
assertEqual(first.length, 2, "first result batch bound");
|
||||
assertEqual(second.length, 2, "second result batch bound");
|
||||
assertEqual(resultBound.getState().availableAnalysisCount, 1, "remaining work stays buffered");
|
||||
});
|
||||
|
||||
test("streaming 분석 취소가 AbortError를 전달하고 계획 lock을 해제한다", async () => {
|
||||
const fixture = createFixture(128);
|
||||
const stream = createStreamingLocalizer({
|
||||
...fixture.config,
|
||||
analysisWindowSamples: 128,
|
||||
analysisHopSamples: 64,
|
||||
maximumBufferedSamples: 256,
|
||||
maximumResultsPerCall: 1,
|
||||
});
|
||||
stream.append(fixture.channels);
|
||||
const controller = new AbortController();
|
||||
let cancellationRequested = false;
|
||||
let caught;
|
||||
try {
|
||||
await stream.analyzeAvailable({
|
||||
signal: controller.signal,
|
||||
computeOptions: { stftTransformBatchSize: 1 },
|
||||
onProgress: ({ stage, completedTransforms }) => {
|
||||
if (stage === "stft" && completedTransforms > 0 && !cancellationRequested) {
|
||||
cancellationRequested = true;
|
||||
controller.abort();
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
assert(cancellationRequested, "cancellation requested during STFT");
|
||||
assertEqual(caught?.name, "AbortError", "AbortError propagated");
|
||||
assertEqual(stream.getState().busy, false, "stream lock released");
|
||||
assertEqual(stream.plan.dasPlan.busy, false, "DAS plan lock released");
|
||||
let state = stream.getState();
|
||||
assertEqual(state.bufferStartSample, 0, "failed window origin retained");
|
||||
assertEqual(state.bufferedSamples, 128, "failed window samples retained");
|
||||
assertEqual(state.availableAnalysisCount, 1, "failed window remains retryable");
|
||||
assertEqual(state.analysisAttempts, 1, "failed attempt counted");
|
||||
assertEqual(state.completedAnalyses, 0, "no completed analysis on abort");
|
||||
assertEqual(state.failedAnalyses, 1, "failed analysis counted");
|
||||
assertEqual(state.nextSequence, 0, "sequence is not consumed by abort");
|
||||
|
||||
const retried = await stream.analyzeAvailable();
|
||||
assertEqual(retried.length, 1, "failed window retried once");
|
||||
assertEqual(retried[0].startSample, 0, "same window retried");
|
||||
assertEqual(retried[0].sequence, 0, "first success receives first sequence");
|
||||
state = stream.getState();
|
||||
assertEqual(state.bufferStartSample, 64, "hop committed after retry success");
|
||||
assertEqual(state.bufferedSamples, 64, "only successful hop consumed");
|
||||
assertEqual(state.analysisAttempts, 2, "retry attempt counted");
|
||||
assertEqual(state.completedAnalyses, 1, "retry completion counted");
|
||||
assertEqual(state.failedAnalyses, 1, "historical failure retained");
|
||||
assertEqual(state.nextSequence, 1, "sequence committed after success");
|
||||
});
|
||||
|
||||
test("onResult 예외는 성공한 window를 실패나 재시도로 되돌리지 않는다", async () => {
|
||||
const fixture = createFixture(128);
|
||||
const stream = createStreamingLocalizer({
|
||||
...fixture.config,
|
||||
analysisWindowSamples: 128,
|
||||
analysisHopSamples: 64,
|
||||
maximumBufferedSamples: 256,
|
||||
maximumResultsPerCall: 1,
|
||||
});
|
||||
stream.append(fixture.channels);
|
||||
let callbackResult;
|
||||
let callbackError;
|
||||
try {
|
||||
await stream.analyzeAvailable({
|
||||
onResult: (result) => {
|
||||
callbackResult = result;
|
||||
throw new Error("consumer failed");
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
callbackError = error;
|
||||
}
|
||||
|
||||
assertEqual(callbackError?.message, "consumer failed", "consumer error propagated");
|
||||
assertEqual(callbackResult?.sequence, 0, "committed result reached consumer");
|
||||
let state = stream.getState();
|
||||
assertEqual(state.bufferStartSample, 64, "callback failure does not roll back hop");
|
||||
assertEqual(state.bufferedSamples, 64, "callback failure leaves committed buffer");
|
||||
assertEqual(state.analysisAttempts, 1, "one localization attempt");
|
||||
assertEqual(state.completedAnalyses, 1, "localization remains completed");
|
||||
assertEqual(state.failedAnalyses, 0, "consumer error is not localization failure");
|
||||
assertEqual(state.nextSequence, 1, "sequence remains committed");
|
||||
|
||||
stream.append(sliceChannels(fixture.channels, 64, 128));
|
||||
const next = await stream.analyzeAvailable();
|
||||
assertEqual(next[0].startSample, 64, "next window advances instead of retrying");
|
||||
assertEqual(next[0].sequence, 1, "next sequence remains contiguous");
|
||||
state = stream.getState();
|
||||
assertEqual(state.bufferStartSample, 128, "second successful hop committed");
|
||||
assertEqual(state.bufferedSamples, 64, "overlap retained after second window");
|
||||
assertEqual(state.analysisAttempts, 2, "second attempt counted");
|
||||
assertEqual(state.completedAnalyses, 2, "second completion counted");
|
||||
assertEqual(state.failedAnalyses, 0, "no false failure after recovery");
|
||||
assertEqual(state.nextSequence, 2, "sequence advanced twice");
|
||||
});
|
||||
@@ -81,6 +81,61 @@ test("STFT batch를 bin-frame-microphone-complex 순서로 packing한다", () =>
|
||||
expected.forEach((value, index) => assertEqual(packed[index], value, `packed STFT value ${index}`));
|
||||
});
|
||||
|
||||
test("WebGPU STFT packing이 microphone 주파수 응답을 bin별로 역보정한다", () => {
|
||||
const stft = {
|
||||
frameCount: 1,
|
||||
microphoneCount: 1,
|
||||
spectra: new Map([[8, { real: Float32Array.of(0), imaginary: Float32Array.of(2) }]]),
|
||||
};
|
||||
const microphones = [{
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 0, gainDb: 6.020599913, phaseDeg: 90 },
|
||||
{ frequencyHz: 4000, gainDb: 6.020599913, phaseDeg: 90 },
|
||||
],
|
||||
}];
|
||||
const compensated = packStftBatch(stft, [8], {
|
||||
microphones,
|
||||
sampleRate: 8000,
|
||||
fftSize: 256,
|
||||
});
|
||||
assertNear(compensated[0], 1, 1e-6, "inverse response real");
|
||||
assertNear(compensated[1], 0, 1e-6, "inverse response imaginary");
|
||||
|
||||
const uncompensated = packStftBatch(stft, [8], {
|
||||
microphones,
|
||||
sampleRate: 8000,
|
||||
fftSize: 256,
|
||||
compensateFrequencyResponse: false,
|
||||
});
|
||||
assertEqual(uncompensated[0], 0, "disabled compensation real");
|
||||
assertEqual(uncompensated[1], 2, "disabled compensation imaginary");
|
||||
|
||||
const notchedMicrophones = [{
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 0, gainDb: -120 },
|
||||
{ frequencyHz: 4000, gainDb: -120 },
|
||||
],
|
||||
}];
|
||||
const notched = {
|
||||
frameCount: 1,
|
||||
microphoneCount: 1,
|
||||
spectra: new Map([[8, { real: Float32Array.of(1), imaginary: Float32Array.of(0) }]]),
|
||||
};
|
||||
const limited = packStftBatch(notched, [8], {
|
||||
microphones: notchedMicrophones,
|
||||
sampleRate: 8000,
|
||||
fftSize: 256,
|
||||
});
|
||||
const explicitLegacyLimit = packStftBatch(notched, [8], {
|
||||
microphones: notchedMicrophones,
|
||||
sampleRate: 8000,
|
||||
fftSize: 256,
|
||||
maxCompensationDb: 120,
|
||||
});
|
||||
assertNear(limited[0], 10 ** (24 / 20), 1e-5, "default notch boost limit");
|
||||
assert(explicitLegacyLimit[0] > 999_000, "explicit 120 dB notch boost");
|
||||
});
|
||||
|
||||
test("WebGPU batch size가 storage, dispatch, memory 한도를 모두 지킨다", () => {
|
||||
const workload = {
|
||||
selectedBins: [4, 5, 6, 7, 8],
|
||||
|
||||
Reference in New Issue
Block a user