docs: guide acoustic modeling and realtime porting
This commit is contained in:
@@ -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 후보들이다.
|
||||
Reference in New Issue
Block a user