Compare commits

...

7 Commits

Author SHA1 Message Date
Codex 85ad056a73 docs: guide acoustic modeling and realtime porting 2026-08-28 19:02:13 +09:00
Codex 713debaf9b fix: clarify snapshot and calibration controls 2026-08-28 18:59:18 +09:00
Codex 256558e601 perf: harden realtime localization pipeline 2026-08-28 18:58:48 +09:00
Codex 35c33e360e refactor: simplify primary workflows 2026-08-28 18:33:20 +09:00
Codex 6e5bd512b9 feat: expose acoustic realism controls 2026-08-28 18:24:10 +09:00
Codex d002a2bd70 feat: add realistic portable localization core 2026-08-28 18:19:31 +09:00
Codex bc63ada1cd feat: refocus acoustic camera workflow 2026-08-28 18:08:52 +09:00
43 changed files with 5526 additions and 553 deletions
+39 -9
View File
@@ -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): 프로젝트 목표
+63 -12
View File
@@ -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>을 여세요.
+122
View File
@@ -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
View File
@@ -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>
+74 -4
View File
@@ -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();
+17
View File
@@ -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,
+4 -1
View File
@@ -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
View File
@@ -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; }
}
+76 -56
View File
@@ -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>
+360
View File
@@ -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;
}
}
+182
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+332
View File
@@ -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
View File
@@ -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>
+115 -7
View File
@@ -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
View File
@@ -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;
}
}
+244
View File
@@ -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;
}
+89 -9
View File
@@ -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);
}
+3
View File
@@ -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";
+50 -18
View File
@@ -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);
}
+43 -10
View File
@@ -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);
}
}
+119 -2
View File
@@ -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;
+215
View File
@@ -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;
}
}
+65 -4
View File
@@ -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
View File
@@ -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 근사의 입력입니다.
+14 -1
View File
@@ -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 }
}
}
}
+3 -1
View File
@@ -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": {
+13 -1
View File
@@ -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": {
+62
View File
@@ -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"],
+68
View File
@@ -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) {
+51 -1
View File
@@ -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;
+1
View File
@@ -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");
+2
View File
@@ -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
+264
View File
@@ -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 () => {
+67 -1
View File
@@ -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)}`);
+67
View File
@@ -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 () => {
+526
View File
@@ -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");
});
+55
View File
@@ -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],