feat: add realistic portable localization core
This commit is contained in:
@@ -3,11 +3,56 @@
|
||||
두 앱이 공유하는 무의존성 수치 기준 구현입니다.
|
||||
|
||||
- 결정적 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 },
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
한 블록만 처리할 때는 `createLocalizationPlan()`과 `localizeMultichannel()`을 사용합니다.
|
||||
`getState()`은 absolute sample origin, buffered/dropped sample, 대기 window 수와 고정 input
|
||||
buffer byte 수를 돌려주므로 capture adapter가 지연을 감시할 수 있습니다. 실제 장치의 clock,
|
||||
channel framing과 transport는 하드웨어별 adapter 책임이며 이 모듈은 그 다음 단계부터 시작합니다.
|
||||
|
||||
반복 CPU 분석 계획은 거리 행렬, bin calibration, 제한 내의 Float64 steering matrix와 임시 bin map을
|
||||
재사용합니다. 예를 들어 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 steering 한도를
|
||||
넘는 설정은 steering cache만 끕니다. 자동 테스트는 cache 전후 raw/display Float32 map의 전 요소
|
||||
bit-exact 일치를 고정합니다.
|
||||
|
||||
## 이동 음원과 분석 시간창
|
||||
|
||||
`sourcePositionAtTime`은 scene 시작 기준의 절대 시각에서 trajectory keyframe 사이를 선형 보간하며,
|
||||
@@ -33,8 +78,40 @@ calibration 값의 역 gain/phase로 보정합니다. raw power map과 표시용
|
||||
buffer로 보존합니다. bin spectrum은 제한된 batch로 처리하며 point batch 사이에서 event loop를
|
||||
양보해 진행 callback과 `AbortSignal` 취소가 동작합니다.
|
||||
|
||||
현재 전파 모델은 자유음장·무지향성 점 음원이며 잔향은 포함하지 않습니다. 전체 signal을
|
||||
메모리에 보관하는 CPU 기준 구현이므로 source/channel과 delay FFT working set을 512 MiB로 제한합니다.
|
||||
장시간 입력은 이후 chunk/STFT streaming backend에서 지원합니다.
|
||||
마이크별 `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 한도를 넘으면 자동으로 끕니다.
|
||||
|
||||
192채널·50×50 fixture는 브라우저 통합 테스트에서 실제 합성부터 DAS peak 검출까지 실행합니다.
|
||||
|
||||
+399
-27
@@ -1,4 +1,5 @@
|
||||
import { dbToLinear, createScanPoints, distance3, findPeak, scanIndexToPosition, TAU } from "./math.js";
|
||||
import { interpolateFrequencyResponse } from "./frequency-response.js";
|
||||
import { binsForBand, extractStftBins, getFrameCount } from "./stft.js";
|
||||
|
||||
function throwIfAborted(signal) {
|
||||
@@ -13,6 +14,10 @@ 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);
|
||||
const microphoneCount = arrayDefinition.microphones.length;
|
||||
@@ -31,6 +36,94 @@ 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) {
|
||||
const microphoneCount = arrayDefinition.microphones.length;
|
||||
const gains = new Float64Array(microphoneCount);
|
||||
const phases = new Float64Array(microphoneCount);
|
||||
const frequencyHz = (bin * sampleRate) / fftSize;
|
||||
const response = {};
|
||||
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;
|
||||
}
|
||||
gains[microphoneIndex] = dbToLinear(
|
||||
-(microphone.sensitivityDb ?? 0) - responseGainDb,
|
||||
);
|
||||
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) await yieldToEventLoop();
|
||||
}
|
||||
return { real, imaginary };
|
||||
}
|
||||
|
||||
export async function computeBinMap(options) {
|
||||
const {
|
||||
bin,
|
||||
@@ -42,26 +135,64 @@ export async function computeBinMap(options) {
|
||||
sampleRate,
|
||||
fftSize,
|
||||
speedOfSound,
|
||||
compensateFrequencyResponse = true,
|
||||
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,
|
||||
);
|
||||
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,8 +204,8 @@ 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;
|
||||
}
|
||||
@@ -86,11 +217,200 @@ export async function computeBinMap(options) {
|
||||
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 retainedBaseBytes =
|
||||
distanceMatrixBytes +
|
||||
pointCount * Float32Array.BYTES_PER_ELEMENT +
|
||||
microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2;
|
||||
if (
|
||||
!Number.isSafeInteger(pointMicrophonePairs) ||
|
||||
!Number.isSafeInteger(retainedBaseBytes) ||
|
||||
retainedBaseBytes > maximumPlanBytes
|
||||
) {
|
||||
throw new RangeError(
|
||||
"CPU analysis plan requires " + retainedBaseBytes.toLocaleString() +
|
||||
" base 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 steeringBytesPerBin =
|
||||
pointCount * microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2;
|
||||
const requestedSteeringCacheBytes = steeringBytesPerBin * selectedBins.length;
|
||||
const steeringCacheEnabled =
|
||||
Number.isSafeInteger(requestedSteeringCacheBytes) &&
|
||||
requestedSteeringCacheBytes > 0 &&
|
||||
requestedSteeringCacheBytes <= maximumSteeringCacheBytes &&
|
||||
retainedBaseBytes + requestedSteeringCacheBytes <= maximumPlanBytes;
|
||||
|
||||
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,
|
||||
steeringBytesPerBin,
|
||||
requestedSteeringCacheBytes,
|
||||
maximumSteeringCacheBytes,
|
||||
maximumPlanBytes,
|
||||
binMap,
|
||||
steeringReal,
|
||||
steeringImaginary,
|
||||
busy: false,
|
||||
statistics: {
|
||||
analysisAttempts: 0,
|
||||
completedAnalyses: 0,
|
||||
distanceMatrixBuilds: 1,
|
||||
distanceEvaluations: pointCount * microphoneCount,
|
||||
avoidedDistanceEvaluations: 0,
|
||||
calibrationBuilds: 0,
|
||||
calibrationCacheHits: 0,
|
||||
steeringMatrixBuilds: 0,
|
||||
steeringCacheHits: 0,
|
||||
steeringPointMicrophonePairs: 0,
|
||||
avoidedSteeringPointMicrophonePairs: 0,
|
||||
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.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.steeringCacheEnabled) 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,
|
||||
steeringCacheEnabled: plan.steeringCacheEnabled,
|
||||
steeringMatrixBuilds: plan.statistics.steeringMatrixBuilds,
|
||||
steeringCacheHits: plan.statistics.steeringCacheHits,
|
||||
steeringPointMicrophonePairs: plan.statistics.steeringPointMicrophonePairs,
|
||||
avoidedSteeringPointMicrophonePairs:
|
||||
plan.statistics.avoidedSteeringPointMicrophonePairs,
|
||||
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;
|
||||
@@ -100,7 +420,11 @@ export function estimateDasWorkload(options) {
|
||||
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 steeringCacheBytes = analysisPlan?.steeringCacheEnabled
|
||||
? analysisPlan.requestedSteeringCacheBytes
|
||||
: 0;
|
||||
const baseWorkingBytes =
|
||||
distanceBytes + outputBytes + binMapBytes + fftWorkspaceBytes + steeringCacheBytes;
|
||||
const complexMacCount = pointCount * microphoneCount * frameCount * selectedBins.length;
|
||||
return {
|
||||
bandBins,
|
||||
@@ -110,6 +434,7 @@ export function estimateDasWorkload(options) {
|
||||
pointCount,
|
||||
distanceBytes,
|
||||
outputBytes,
|
||||
steeringCacheBytes,
|
||||
spectrumBytesPerBin,
|
||||
baseWorkingBytes,
|
||||
complexMacCount,
|
||||
@@ -147,7 +472,7 @@ export function finalizeDasBands(rawMaps, workload, experiment) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function computeDasMap(options) {
|
||||
async function computeDasMapInternal(options, analysisPlan) {
|
||||
const {
|
||||
channels,
|
||||
sampleRate,
|
||||
@@ -167,7 +492,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,15 +530,10 @@ 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) {
|
||||
@@ -236,6 +562,17 @@ export async function computeDasMap(options) {
|
||||
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,6 +583,13 @@ export async function computeDasMap(options) {
|
||||
sampleRate,
|
||||
fftSize: experiment.stft.fftSize,
|
||||
speedOfSound: scene.speedOfSound,
|
||||
compensateFrequencyResponse:
|
||||
experiment.beamforming.compensateFrequencyResponse !== false,
|
||||
calibration,
|
||||
steeringMatrix,
|
||||
output: analysisPlan?.binMap,
|
||||
steeringReal: analysisPlan?.steeringReal,
|
||||
steeringImaginary: analysisPlan?.steeringImaginary,
|
||||
signal,
|
||||
pointBatchSize,
|
||||
});
|
||||
@@ -270,5 +614,33 @@ export async function computeDasMap(options) {
|
||||
workload,
|
||||
fftButterflyCount,
|
||||
bands,
|
||||
...(analysisPlan ? { analysisCache: analysisCacheSummary(analysisPlan) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function computeDasMap(options) {
|
||||
const analysisPlan = options.analysisPlan;
|
||||
if (!analysisPlan) return computeDasMapInternal(options, undefined);
|
||||
assertCompatibleAnalysisPlan(analysisPlan, options);
|
||||
if (analysisPlan.busy) {
|
||||
throw new Error("analysisPlan is already in use by another DAS analysis.");
|
||||
}
|
||||
analysisPlan.busy = true;
|
||||
analysisPlan.statistics.analysisAttempts += 1;
|
||||
if (analysisPlan.statistics.analysisAttempts > 1) {
|
||||
analysisPlan.statistics.avoidedDistanceEvaluations +=
|
||||
analysisPlan.pointCount * analysisPlan.microphoneCount;
|
||||
}
|
||||
try {
|
||||
const result = await computeDasMapInternal(options, analysisPlan);
|
||||
analysisPlan.statistics.completedAnalyses += 1;
|
||||
result.analysisCache = {
|
||||
...result.analysisCache,
|
||||
analysisAttempts: analysisPlan.statistics.analysisAttempts,
|
||||
completedAnalyses: analysisPlan.statistics.completedAnalyses,
|
||||
};
|
||||
return result;
|
||||
} finally {
|
||||
analysisPlan.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { dbToLinear, distance3 } from "./math.js";
|
||||
import { hashString, whiteNoiseAt } from "./noise.js";
|
||||
|
||||
const WALLS = [
|
||||
{ id: "xMin", axis: 0, boundary: "min" },
|
||||
{ id: "xMax", axis: 0, boundary: "max" },
|
||||
{ id: "yMin", axis: 1, boundary: "min" },
|
||||
{ id: "yMax", axis: 1, boundary: "max" },
|
||||
{ id: "zMin", axis: 2, boundary: "min" },
|
||||
{ id: "zMax", axis: 2, boundary: "max" },
|
||||
];
|
||||
|
||||
const GEOMETRY_EPSILON = 1e-9;
|
||||
|
||||
function insideBounds(position, bounds, epsilon = GEOMETRY_EPSILON) {
|
||||
return position.every(
|
||||
(coordinate, axis) =>
|
||||
coordinate >= bounds.min[axis] - epsilon && coordinate <= bounds.max[axis] + epsilon,
|
||||
);
|
||||
}
|
||||
|
||||
function segmentIntersectsBox(start, end, box) {
|
||||
let entry = 0;
|
||||
let exit = 1;
|
||||
for (let axis = 0; axis < 3; axis += 1) {
|
||||
const delta = end[axis] - start[axis];
|
||||
if (Math.abs(delta) <= GEOMETRY_EPSILON) {
|
||||
if (start[axis] < box.min[axis] || start[axis] > box.max[axis]) return false;
|
||||
continue;
|
||||
}
|
||||
const first = (box.min[axis] - start[axis]) / delta;
|
||||
const second = (box.max[axis] - start[axis]) / delta;
|
||||
entry = Math.max(entry, Math.min(first, second));
|
||||
exit = Math.min(exit, Math.max(first, second));
|
||||
if (entry > exit) return false;
|
||||
}
|
||||
return exit > GEOMETRY_EPSILON && entry < 1 - GEOMETRY_EPSILON;
|
||||
}
|
||||
|
||||
function shortestCornerDetour(start, end, box) {
|
||||
let shortestDistance = Infinity;
|
||||
for (let mask = 0; mask < 8; mask += 1) {
|
||||
const corner = [0, 1, 2].map((axis) =>
|
||||
mask & (1 << axis) ? box.max[axis] : box.min[axis],
|
||||
);
|
||||
shortestDistance = Math.min(
|
||||
shortestDistance,
|
||||
distance3(start, corner) + distance3(corner, end),
|
||||
);
|
||||
}
|
||||
return shortestDistance;
|
||||
}
|
||||
|
||||
function imageSourceDistance(source, receiver, room, wall) {
|
||||
if (!insideBounds(source, room) || !insideBounds(receiver, room)) return null;
|
||||
const plane = room[wall.boundary][wall.axis];
|
||||
const image = [...source];
|
||||
image[wall.axis] = 2 * plane - source[wall.axis];
|
||||
const denominator = receiver[wall.axis] - image[wall.axis];
|
||||
if (Math.abs(denominator) <= GEOMETRY_EPSILON) return null;
|
||||
const amount = (plane - image[wall.axis]) / denominator;
|
||||
if (amount <= GEOMETRY_EPSILON || amount >= 1 - GEOMETRY_EPSILON) return null;
|
||||
const reflectionPoint = image.map(
|
||||
(coordinate, axis) => coordinate + (receiver[axis] - coordinate) * amount,
|
||||
);
|
||||
for (let axis = 0; axis < 3; axis += 1) {
|
||||
if (axis === wall.axis) continue;
|
||||
if (
|
||||
reflectionPoint[axis] < room.min[axis] - GEOMETRY_EPSILON ||
|
||||
reflectionPoint[axis] > room.max[axis] + GEOMETRY_EPSILON
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return distance3(image, receiver);
|
||||
}
|
||||
|
||||
function pathGain(distance, coefficient, referenceDistance, minimumDistance) {
|
||||
return coefficient * referenceDistance / Math.max(distance, minimumDistance);
|
||||
}
|
||||
|
||||
function deterministicUnit(seed, index) {
|
||||
return (whiteNoiseAt(seed, index) + 1) / 2;
|
||||
}
|
||||
|
||||
function stableSeed(scene, sourceId, receiverId) {
|
||||
return (
|
||||
(scene.seed ?? 0) ^
|
||||
hashString(String(sourceId ?? "source")) ^
|
||||
Math.imul(hashString(String(receiverId ?? "receiver")), 0x45d9f3b)
|
||||
) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a sparse, deterministic set of propagation paths for one source/receiver pair.
|
||||
*
|
||||
* The optional shoebox model is intentionally bounded: direct sound, six first-order
|
||||
* image sources, at most one edge-detour per occluder, and a capped diffuse tail.
|
||||
* It is useful for array/algorithm comparisons; it is not a room-acoustics solver.
|
||||
*/
|
||||
export function buildAcousticPaths(options) {
|
||||
const {
|
||||
scene,
|
||||
sourcePosition,
|
||||
receiverPosition,
|
||||
sourceId,
|
||||
receiverId,
|
||||
referenceDistance = 1,
|
||||
minimumDistance = 0.01,
|
||||
} = options;
|
||||
const speedOfSound = scene.speedOfSound;
|
||||
const directDistance = distance3(sourcePosition, receiverPosition);
|
||||
const acoustics = scene.acoustics;
|
||||
if (!acoustics || acoustics.model === "free-field") {
|
||||
return [{
|
||||
kind: "direct",
|
||||
distance: directDistance,
|
||||
delaySeconds: directDistance / speedOfSound,
|
||||
gain: pathGain(directDistance, 1, referenceDistance, minimumDistance),
|
||||
}];
|
||||
}
|
||||
|
||||
const paths = [];
|
||||
const blockers = (acoustics.occluders ?? []).filter((occluder) =>
|
||||
segmentIntersectsBox(sourcePosition, receiverPosition, occluder),
|
||||
);
|
||||
const transmission = blockers.reduce(
|
||||
(coefficient, occluder) => coefficient * dbToLinear(occluder.transmissionDb),
|
||||
1,
|
||||
);
|
||||
paths.push({
|
||||
kind: "direct",
|
||||
distance: directDistance,
|
||||
delaySeconds: directDistance / speedOfSound,
|
||||
gain: pathGain(directDistance, transmission, referenceDistance, minimumDistance),
|
||||
blockedBy: blockers.map(({ id }) => id),
|
||||
});
|
||||
|
||||
for (const occluder of blockers) {
|
||||
const distance = shortestCornerDetour(sourcePosition, receiverPosition, occluder);
|
||||
paths.push({
|
||||
kind: "diffraction",
|
||||
occluderId: occluder.id,
|
||||
distance,
|
||||
delaySeconds: distance / speedOfSound,
|
||||
gain: pathGain(
|
||||
distance,
|
||||
dbToLinear(occluder.diffractionGainDb),
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const reflections = acoustics.reflections;
|
||||
const seed = stableSeed(scene, sourceId, receiverId);
|
||||
if (reflections?.order === 1 && acoustics.room) {
|
||||
const scattering = reflections.scattering ?? 0;
|
||||
for (let wallIndex = 0; wallIndex < WALLS.length; wallIndex += 1) {
|
||||
const wall = WALLS[wallIndex];
|
||||
const distance = imageSourceDistance(
|
||||
sourcePosition,
|
||||
receiverPosition,
|
||||
acoustics.room,
|
||||
wall,
|
||||
);
|
||||
if (distance === null) continue;
|
||||
const wallDb = reflections.perWallDb?.[wall.id] ?? reflections.wallGainDb;
|
||||
const reflectionCoefficient = dbToLinear(wallDb);
|
||||
const specularCoefficient = reflectionCoefficient * Math.sqrt(1 - scattering);
|
||||
if (specularCoefficient > 0) {
|
||||
paths.push({
|
||||
kind: "reflection",
|
||||
surface: wall.id,
|
||||
distance,
|
||||
delaySeconds: distance / speedOfSound,
|
||||
gain: pathGain(distance, specularCoefficient, referenceDistance, minimumDistance),
|
||||
});
|
||||
}
|
||||
if (scattering > 0) {
|
||||
const jitterSeconds = 0.0002 + deterministicUnit(seed, wallIndex) * 0.0018;
|
||||
const sign = whiteNoiseAt(seed ^ 0x9e3779b9, wallIndex) < 0 ? -1 : 1;
|
||||
paths.push({
|
||||
kind: "scattering",
|
||||
surface: wall.id,
|
||||
distance: distance + jitterSeconds * speedOfSound,
|
||||
delaySeconds: distance / speedOfSound + jitterSeconds,
|
||||
gain: pathGain(
|
||||
distance,
|
||||
reflectionCoefficient * Math.sqrt(scattering) * sign,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const diffuse = acoustics.diffuseReverb;
|
||||
if (diffuse?.enabled) {
|
||||
const tapGain = dbToLinear(diffuse.gainDb) / Math.sqrt(diffuse.tapCount);
|
||||
for (let tapIndex = 0; tapIndex < diffuse.tapCount; tapIndex += 1) {
|
||||
const progress = (tapIndex + 1) / (diffuse.tapCount + 1);
|
||||
const tailTime = progress * diffuse.rt60Seconds;
|
||||
const jitterLimit = Math.min(0.001, diffuse.rt60Seconds / (diffuse.tapCount * 4));
|
||||
const jitter = deterministicUnit(seed ^ 0xa511e9b3, tapIndex) * jitterLimit;
|
||||
const decay = 10 ** ((-60 * tailTime / diffuse.rt60Seconds) / 20);
|
||||
const sign = whiteNoiseAt(seed ^ 0x63d83595, tapIndex) < 0 ? -1 : 1;
|
||||
paths.push({
|
||||
kind: "diffuse",
|
||||
distance: directDistance + (tailTime + jitter) * speedOfSound,
|
||||
delaySeconds: directDistance / speedOfSound + tailTime + jitter,
|
||||
gain: pathGain(
|
||||
directDistance,
|
||||
tapGain * decay * sign,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function maximumAcousticDelaySeconds(options) {
|
||||
const { scene, arrayDefinition, referenceDistance, minimumDistance } = options;
|
||||
let maximum = 0;
|
||||
for (const source of scene.sources) {
|
||||
for (const microphone of arrayDefinition.microphones) {
|
||||
const paths = buildAcousticPaths({
|
||||
scene,
|
||||
sourcePosition: source.position,
|
||||
receiverPosition: microphone.position,
|
||||
sourceId: source.id,
|
||||
receiverId: microphone.id,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
});
|
||||
for (const path of paths) maximum = Math.max(maximum, path.delaySeconds);
|
||||
}
|
||||
}
|
||||
return maximum;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fftInPlace } from "./fft.js";
|
||||
import { interpolateFrequencyResponse } from "./frequency-response.js";
|
||||
import { TAU } from "./math.js";
|
||||
|
||||
export function nextPowerOfTwo(value) {
|
||||
@@ -21,25 +22,96 @@ export function createDelayWorkspace(fftSize) {
|
||||
return { real: new Float64Array(fftSize), imaginary: new Float64Array(fftSize) };
|
||||
}
|
||||
|
||||
export function addFrequencyDomainDelay(options) {
|
||||
const { output, spectrum, delaySamples, gain = 1, phaseOffsetDeg = 0, workspace } = options;
|
||||
if (!(delaySamples >= 0) || !Number.isFinite(delaySamples)) throw new Error(`유효하지 않은 delay: ${delaySamples}`);
|
||||
export function addFrequencyDomainPaths(options) {
|
||||
const {
|
||||
output,
|
||||
spectrum,
|
||||
paths,
|
||||
gain = 1,
|
||||
phaseOffsetDeg = 0,
|
||||
frequencyResponse,
|
||||
sampleRate,
|
||||
workspace,
|
||||
} = options;
|
||||
if (!Array.isArray(paths) || paths.length === 0) {
|
||||
throw new Error("frequency-domain path가 하나 이상 필요합니다.");
|
||||
}
|
||||
for (const path of paths) {
|
||||
if (!(path.delaySamples >= 0) || !Number.isFinite(path.delaySamples)) {
|
||||
throw new Error("유효하지 않은 delay: " + path.delaySamples);
|
||||
}
|
||||
if (!Number.isFinite(path.gain)) throw new Error("path gain은 유한한 숫자여야 합니다.");
|
||||
}
|
||||
if (workspace.real.length !== spectrum.fftSize || workspace.imaginary.length !== spectrum.fftSize) {
|
||||
throw new Error("fractional-delay workspace 크기가 spectrum과 다릅니다.");
|
||||
}
|
||||
const hasFrequencyResponse = Array.isArray(frequencyResponse) && frequencyResponse.length > 0;
|
||||
if (hasFrequencyResponse && (!(sampleRate > 0) || !Number.isFinite(sampleRate))) {
|
||||
throw new Error("frequencyResponse 적용에는 유효한 sampleRate가 필요합니다.");
|
||||
}
|
||||
|
||||
const size = spectrum.fftSize;
|
||||
const calibrationPhase = (phaseOffsetDeg * Math.PI) / 180;
|
||||
const response = {};
|
||||
workspace.real.fill(0);
|
||||
workspace.imaginary.fill(0);
|
||||
const halfSize = size / 2;
|
||||
const phaseReanchorInterval = 1024;
|
||||
for (const path of paths) {
|
||||
const phaseStep = (-TAU * path.delaySamples) / size;
|
||||
const stepReal = Math.cos(phaseStep);
|
||||
const stepImaginary = Math.sin(phaseStep);
|
||||
let phaseReal = 1;
|
||||
let phaseImaginary = 0;
|
||||
for (let bin = 0; bin <= halfSize; bin += 1) {
|
||||
workspace.real[bin] += phaseReal * path.gain;
|
||||
workspace.imaginary[bin] += phaseImaginary * path.gain;
|
||||
if (bin > 0 && bin < halfSize) {
|
||||
const negativeBin = size - bin;
|
||||
workspace.real[negativeBin] += phaseReal * path.gain;
|
||||
workspace.imaginary[negativeBin] -= phaseImaginary * path.gain;
|
||||
}
|
||||
const nextBin = bin + 1;
|
||||
if (nextBin % phaseReanchorInterval === 0) {
|
||||
phaseReal = Math.cos(phaseStep * nextBin);
|
||||
phaseImaginary = Math.sin(phaseStep * nextBin);
|
||||
} else {
|
||||
const nextReal = phaseReal * stepReal - phaseImaginary * stepImaginary;
|
||||
phaseImaginary = phaseReal * stepImaginary + phaseImaginary * stepReal;
|
||||
phaseReal = nextReal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let bin = 0; bin < size; bin += 1) {
|
||||
const signedBin = bin <= size / 2 ? bin : bin - size;
|
||||
const phaseSign = signedBin === 0 ? 0 : signedBin > 0 ? 1 : -1;
|
||||
const phase = (-TAU * signedBin * delaySamples) / size + phaseSign * calibrationPhase;
|
||||
const phaseReal = Math.cos(phase);
|
||||
const phaseImaginary = Math.sin(phase);
|
||||
let responseGain = 1;
|
||||
let responsePhase = 0;
|
||||
if (hasFrequencyResponse) {
|
||||
interpolateFrequencyResponse(
|
||||
frequencyResponse,
|
||||
(Math.abs(signedBin) * sampleRate) / size,
|
||||
response,
|
||||
);
|
||||
responseGain = response.linearGain;
|
||||
responsePhase = response.phaseRadians;
|
||||
}
|
||||
const pathReal = workspace.real[bin];
|
||||
const pathImaginary = workspace.imaginary[bin];
|
||||
const responseAndCalibrationPhase = phaseSign * (calibrationPhase + responsePhase);
|
||||
const calibrationReal = Math.cos(responseAndCalibrationPhase) * responseGain * gain;
|
||||
const calibrationImaginary = Math.sin(responseAndCalibrationPhase) * responseGain * gain;
|
||||
const transferReal =
|
||||
pathReal * calibrationReal - pathImaginary * calibrationImaginary;
|
||||
const transferImaginary =
|
||||
pathReal * calibrationImaginary + pathImaginary * calibrationReal;
|
||||
const inputReal = spectrum.real[bin];
|
||||
const inputImaginary = spectrum.imaginary[bin];
|
||||
workspace.real[bin] = inputReal * phaseReal - inputImaginary * phaseImaginary;
|
||||
workspace.imaginary[bin] = inputReal * phaseImaginary + inputImaginary * phaseReal;
|
||||
workspace.real[bin] =
|
||||
inputReal * transferReal - inputImaginary * transferImaginary;
|
||||
workspace.imaginary[bin] =
|
||||
inputReal * transferImaginary + inputImaginary * transferReal;
|
||||
}
|
||||
|
||||
// A real sequence has a real-valued Nyquist coefficient. Projecting this single self-conjugate
|
||||
@@ -48,6 +120,14 @@ export function addFrequencyDomainDelay(options) {
|
||||
fftInPlace(workspace.real, workspace.imaginary, true);
|
||||
const outputLength = Math.min(output.length, workspace.real.length);
|
||||
for (let sampleIndex = 0; sampleIndex < outputLength; sampleIndex += 1) {
|
||||
output[sampleIndex] += workspace.real[sampleIndex] * gain;
|
||||
output[sampleIndex] += workspace.real[sampleIndex];
|
||||
}
|
||||
}
|
||||
|
||||
export function addFrequencyDomainDelay(options) {
|
||||
const { delaySamples, gain = 1, ...shared } = options;
|
||||
return addFrequencyDomainPaths({
|
||||
...shared,
|
||||
paths: [{ delaySamples, gain }],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { dbToLinear } from "./math.js";
|
||||
|
||||
export const MAX_FREQUENCY_RESPONSE_POINTS = 64;
|
||||
export const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000;
|
||||
|
||||
function phaseAt(point) {
|
||||
return point.phaseDeg ?? 0;
|
||||
}
|
||||
|
||||
function wrapPhaseDeg(value) {
|
||||
const wrapped = ((value + 180) % 360 + 360) % 360 - 180;
|
||||
return Object.is(wrapped, -0) ? 0 : wrapped;
|
||||
}
|
||||
|
||||
function writeResponse(output, gainDb, phaseDeg) {
|
||||
output.gainDb = gainDb;
|
||||
output.phaseDeg = wrapPhaseDeg(phaseDeg);
|
||||
output.linearGain = dbToLinear(gainDb);
|
||||
output.phaseRadians = (output.phaseDeg * Math.PI) / 180;
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Piecewise-linear microphone response interpolation.
|
||||
*
|
||||
* Frequencies outside the measured curve use the nearest endpoint. Phase follows
|
||||
* the shortest wrapped path between points, avoiding a discontinuity at ±180°.
|
||||
* Passing an output object lets FFT loops reuse storage and avoid per-bin garbage.
|
||||
*/
|
||||
export function interpolateFrequencyResponse(points, frequencyHz, output = {}) {
|
||||
if (!Number.isFinite(frequencyHz) || frequencyHz < 0) {
|
||||
throw new RangeError(`frequencyHz must be a finite non-negative number: ${frequencyHz}`);
|
||||
}
|
||||
if (points === undefined || points === null || points.length === 0) {
|
||||
return writeResponse(output, 0, 0);
|
||||
}
|
||||
if (!Array.isArray(points)) throw new TypeError("frequency response points must be an array.");
|
||||
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
if (frequencyHz <= first.frequencyHz) {
|
||||
return writeResponse(output, first.gainDb, phaseAt(first));
|
||||
}
|
||||
if (frequencyHz >= last.frequencyHz) {
|
||||
return writeResponse(output, last.gainDb, phaseAt(last));
|
||||
}
|
||||
|
||||
let low = 0;
|
||||
let high = points.length - 1;
|
||||
while (high - low > 1) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if (frequencyHz < points[middle].frequencyHz) high = middle;
|
||||
else low = middle;
|
||||
}
|
||||
const left = points[low];
|
||||
const right = points[high];
|
||||
const ratio = (frequencyHz - left.frequencyHz) / (right.frequencyHz - left.frequencyHz);
|
||||
const gainDb = left.gainDb + (right.gainDb - left.gainDb) * ratio;
|
||||
const leftPhase = phaseAt(left);
|
||||
const phaseDelta = wrapPhaseDeg(phaseAt(right) - leftPhase);
|
||||
return writeResponse(output, gainDb, leftPhase + phaseDelta * ratio);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
export * from "./das.js";
|
||||
export * from "./evaluation.js";
|
||||
export * from "./environment.js";
|
||||
export * from "./fft.js";
|
||||
export * from "./frequency-response.js";
|
||||
export * from "./fractional-delay.js";
|
||||
export * from "./math.js";
|
||||
export * from "./mixdown.js";
|
||||
export * from "./noise.js";
|
||||
export * from "./propagation.js";
|
||||
export * from "./realtime-localizer.js";
|
||||
export * from "./signal.js";
|
||||
export * from "./stft.js";
|
||||
export * from "./trajectory.js";
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { dbToLinear, distance3 } from "./math.js";
|
||||
import {
|
||||
addFrequencyDomainDelay,
|
||||
addFrequencyDomainPaths,
|
||||
createDelayWorkspace,
|
||||
nextPowerOfTwo,
|
||||
prepareDelaySpectrum,
|
||||
} from "./fractional-delay.js";
|
||||
import { buildAcousticPaths, maximumAcousticDelaySeconds } from "./environment.js";
|
||||
import { renderSignal } from "./signal.js";
|
||||
|
||||
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
|
||||
@@ -27,15 +29,13 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
} = options;
|
||||
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
||||
const retainedBufferCount = arrayDefinition.microphones.length + scene.sources.length + 1;
|
||||
let maximumDelaySamples = 0;
|
||||
for (const source of scene.sources) {
|
||||
for (const microphone of arrayDefinition.microphones) {
|
||||
maximumDelaySamples = Math.max(
|
||||
maximumDelaySamples,
|
||||
(distance3(source.position, microphone.position) / scene.speedOfSound) * scene.sampleRate,
|
||||
);
|
||||
}
|
||||
}
|
||||
const maximumDelaySamples =
|
||||
maximumAcousticDelaySeconds({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
}) * scene.sampleRate;
|
||||
const delayFftSize = nextPowerOfTwo(sampleCount + Math.ceil(maximumDelaySamples) + 2);
|
||||
const delayWorkingBytes = delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4;
|
||||
const estimatedBufferBytes =
|
||||
@@ -56,22 +56,49 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
}),
|
||||
}));
|
||||
|
||||
const acousticPathCounts = {};
|
||||
let acousticPathCount = 0;
|
||||
renderedSources.forEach(({ definition, samples }) => {
|
||||
const distances = arrayDefinition.microphones.map((microphone) => distance3(definition.position, microphone.position));
|
||||
const maximumDelaySamples = (Math.max(...distances) / scene.speedOfSound) * scene.sampleRate;
|
||||
const spectrum = prepareDelaySpectrum(samples, maximumDelaySamples);
|
||||
const microphonePaths = arrayDefinition.microphones.map((microphone) =>
|
||||
buildAcousticPaths({
|
||||
scene,
|
||||
sourcePosition: definition.position,
|
||||
receiverPosition: microphone.position,
|
||||
sourceId: definition.id,
|
||||
receiverId: microphone.id,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
}),
|
||||
);
|
||||
let sourceMaximumDelaySamples = 0;
|
||||
for (const paths of microphonePaths) {
|
||||
for (const path of paths) {
|
||||
sourceMaximumDelaySamples = Math.max(
|
||||
sourceMaximumDelaySamples,
|
||||
path.delaySeconds * scene.sampleRate,
|
||||
);
|
||||
}
|
||||
}
|
||||
const spectrum = prepareDelaySpectrum(samples, sourceMaximumDelaySamples);
|
||||
const workspace = createDelayWorkspace(spectrum.fftSize);
|
||||
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
|
||||
const distance = distances[microphoneIndex];
|
||||
const delaySamples = (distance / scene.speedOfSound) * scene.sampleRate;
|
||||
const paths = microphonePaths[microphoneIndex];
|
||||
for (const path of paths) {
|
||||
acousticPathCount += 1;
|
||||
acousticPathCounts[path.kind] = (acousticPathCounts[path.kind] ?? 0) + 1;
|
||||
}
|
||||
const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0);
|
||||
const amplitude = sphericalAttenuation(distance, referenceDistance, minimumDistance) * sensitivity;
|
||||
addFrequencyDomainDelay({
|
||||
addFrequencyDomainPaths({
|
||||
output: channels[microphoneIndex],
|
||||
spectrum,
|
||||
delaySamples,
|
||||
gain: amplitude,
|
||||
paths: paths.map((path) => ({
|
||||
delaySamples: path.delaySeconds * scene.sampleRate,
|
||||
gain: path.gain,
|
||||
})),
|
||||
gain: sensitivity,
|
||||
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
||||
frequencyResponse: microphone.frequencyResponse,
|
||||
sampleRate: scene.sampleRate,
|
||||
workspace,
|
||||
});
|
||||
});
|
||||
@@ -93,6 +120,8 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
delaySamples: 0,
|
||||
gain: dbToLinear(microphone.sensitivityDb ?? 0),
|
||||
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
||||
frequencyResponse: microphone.frequencyResponse,
|
||||
sampleRate: scene.sampleRate,
|
||||
workspace: createDelayWorkspace(spectrum.fftSize),
|
||||
});
|
||||
});
|
||||
@@ -127,5 +156,8 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
appliedScale,
|
||||
clippedSampleCount,
|
||||
estimatedBufferBytes,
|
||||
acousticModel: scene.acoustics?.model ?? "free-field",
|
||||
acousticPathCount,
|
||||
acousticPathCounts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
import { computeDasMap, createDasAnalysisPlan } from "./das.js";
|
||||
import {
|
||||
MAX_FREQUENCY_RESPONSE_HZ,
|
||||
MAX_FREQUENCY_RESPONSE_POINTS,
|
||||
} 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;
|
||||
|
||||
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 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 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);
|
||||
}
|
||||
|
||||
function locationsFromBands(bands) {
|
||||
return bands.map((band) => ({
|
||||
bandId: band.id,
|
||||
index: band.peak.index,
|
||||
position: [...band.peak.position],
|
||||
rawPower: band.peak.rawPower,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 scene = Object.freeze({ speedOfSound });
|
||||
const experiment = Object.freeze({
|
||||
stft: Object.freeze({ fftSize, hopSize, window: "hann" }),
|
||||
scan,
|
||||
frequencyBands,
|
||||
beamforming: Object.freeze({ normalization, compensateFrequencyResponse }),
|
||||
});
|
||||
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,
|
||||
stft: experiment.stft,
|
||||
speedOfSound,
|
||||
normalization,
|
||||
scene,
|
||||
experiment,
|
||||
dasPlan,
|
||||
computeOptions: Object.freeze({ ...(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 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,
|
||||
});
|
||||
return {
|
||||
...das,
|
||||
sampleRate: plan.sampleRate,
|
||||
sampleCount,
|
||||
duration: sampleCount / plan.sampleRate,
|
||||
locations: locationsFromBands(das.bands),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, consumes its
|
||||
* hop, 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.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.nextSequence = 0;
|
||||
this.busy = false;
|
||||
}
|
||||
|
||||
availableAnalysisCount() {
|
||||
if (this.bufferedSamples < this.analysisWindowSamples) return 0;
|
||||
return 1 + Math.floor(
|
||||
(this.bufferedSamples - this.analysisWindowSamples) / this.analysisHopSamples,
|
||||
);
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
sampleRate: this.plan.sampleRate,
|
||||
channelCount: this.plan.channelCount,
|
||||
analysisWindowSamples: this.analysisWindowSamples,
|
||||
analysisHopSamples: this.analysisHopSamples,
|
||||
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,
|
||||
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.bufferedSamples >= this.analysisWindowSamples
|
||||
) {
|
||||
throwIfAborted(options.signal);
|
||||
const startSample = this.bufferStartSample;
|
||||
const endSample = startSample + this.analysisWindowSamples;
|
||||
this.copyAnalysisWindow();
|
||||
this.consume(this.analysisHopSamples, false);
|
||||
this.analysisAttempts += 1;
|
||||
try {
|
||||
const localized = await localizeMultichannel({
|
||||
plan: this.plan,
|
||||
channels: this.analysisChannels,
|
||||
signal: options.signal,
|
||||
onProgress: options.onProgress,
|
||||
computeOptions: options.computeOptions,
|
||||
});
|
||||
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,
|
||||
};
|
||||
this.nextSequence += 1;
|
||||
this.completedAnalyses += 1;
|
||||
results.push(result);
|
||||
options.onResult?.(result);
|
||||
} catch (error) {
|
||||
this.failedAnalyses += 1;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
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.nextSequence = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function createStreamingLocalizer(config) {
|
||||
return new StreamingDasLocalizer(config);
|
||||
}
|
||||
@@ -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,16 @@ 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.evaluation !== undefined && requireRecord(value.evaluation, "$.evaluation", issues)) {
|
||||
const threshold = value.evaluation.detectionThresholdRatio;
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
estimateDasWorkload,
|
||||
finalizeDasBands,
|
||||
} from "../acoustics-core/das.js";
|
||||
import { interpolateFrequencyResponse } 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 +68,45 @@ 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 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(-response.gainDb);
|
||||
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 +114,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 +535,13 @@ 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,
|
||||
});
|
||||
stftMs += now() - stftStartedAt;
|
||||
const binIndices = Uint32Array.from(batchBins);
|
||||
const binResources = [];
|
||||
|
||||
Reference in New Issue
Block a user