docs: guide acoustic modeling and realtime porting

This commit is contained in:
Codex
2026-08-28 19:02:13 +09:00
parent 713debaf9b
commit 85ad056a73
7 changed files with 774 additions and 79 deletions
+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 후보들이다.