feat: add realistic portable localization core
This commit is contained in:
@@ -3,11 +3,56 @@
|
|||||||
두 앱이 공유하는 무의존성 수치 기준 구현입니다.
|
두 앱이 공유하는 무의존성 수치 기준 구현입니다.
|
||||||
|
|
||||||
- 결정적 tone/white/pink noise 및 오디오 asset resampling
|
- 결정적 tone/white/pink noise 및 오디오 asset resampling
|
||||||
- 자유음장 점 음원의 `delay = r / c`, `amplitude = 1 / r` 구면 전파
|
- 자유음장 직접음과 선택적 저비용 shoebox multipath
|
||||||
- zero-padded FFT phase shift 기반 fractional sample delay
|
- zero-padded FFT 전달함수 기반 fractional sample delay와 마이크 주파수 응답
|
||||||
- radix-2 FFT, Hann STFT와 선택 bin 추출
|
- radix-2 FFT, Hann STFT와 선택 bin 추출
|
||||||
- scan grid 거리 cache 및 conventional frequency-domain DAS
|
- 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 사이를 선형 보간하며,
|
`sourcePositionAtTime`은 scene 시작 기준의 절대 시각에서 trajectory keyframe 사이를 선형 보간하며,
|
||||||
@@ -33,8 +78,40 @@ calibration 값의 역 gain/phase로 보정합니다. raw power map과 표시용
|
|||||||
buffer로 보존합니다. bin spectrum은 제한된 batch로 처리하며 point batch 사이에서 event loop를
|
buffer로 보존합니다. bin spectrum은 제한된 batch로 처리하며 point batch 사이에서 event loop를
|
||||||
양보해 진행 callback과 `AbortSignal` 취소가 동작합니다.
|
양보해 진행 callback과 `AbortSignal` 취소가 동작합니다.
|
||||||
|
|
||||||
현재 전파 모델은 자유음장·무지향성 점 음원이며 잔향은 포함하지 않습니다. 전체 signal을
|
마이크별 `frequencyResponse`는 2~64개의 `{ frequencyHz, gainDb, phaseDeg? }` 점으로 정의합니다.
|
||||||
메모리에 보관하는 CPU 기준 구현이므로 source/channel과 delay FFT working set을 512 MiB로 제한합니다.
|
`interpolateFrequencyResponse`는 dB와 wrapped phase를 구간 선형 보간하고 측정 범위 밖에서는 가장
|
||||||
장시간 입력은 이후 chunk/STFT streaming backend에서 지원합니다.
|
가까운 끝점을 사용합니다. 합성 시 기존 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 검출까지 실행합니다.
|
192채널·50×50 fixture는 브라우저 통합 테스트에서 실제 합성부터 DAS peak 검출까지 실행합니다.
|
||||||
|
|||||||
+399
-27
@@ -1,4 +1,5 @@
|
|||||||
import { dbToLinear, createScanPoints, distance3, findPeak, scanIndexToPosition, TAU } from "./math.js";
|
import { dbToLinear, createScanPoints, distance3, findPeak, scanIndexToPosition, TAU } from "./math.js";
|
||||||
|
import { interpolateFrequencyResponse } from "./frequency-response.js";
|
||||||
import { binsForBand, extractStftBins, getFrameCount } from "./stft.js";
|
import { binsForBand, extractStftBins, getFrameCount } from "./stft.js";
|
||||||
|
|
||||||
function throwIfAborted(signal) {
|
function throwIfAborted(signal) {
|
||||||
@@ -13,6 +14,10 @@ function yieldToEventLoop() {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
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) {
|
export function createDistanceMatrix(arrayDefinition, scan) {
|
||||||
const points = createScanPoints(scan);
|
const points = createScanPoints(scan);
|
||||||
const microphoneCount = arrayDefinition.microphones.length;
|
const microphoneCount = arrayDefinition.microphones.length;
|
||||||
@@ -31,6 +36,94 @@ export function createDistanceMatrix(arrayDefinition, scan) {
|
|||||||
return distances;
|
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) {
|
export async function computeBinMap(options) {
|
||||||
const {
|
const {
|
||||||
bin,
|
bin,
|
||||||
@@ -42,26 +135,64 @@ export async function computeBinMap(options) {
|
|||||||
sampleRate,
|
sampleRate,
|
||||||
fftSize,
|
fftSize,
|
||||||
speedOfSound,
|
speedOfSound,
|
||||||
|
compensateFrequencyResponse = true,
|
||||||
|
calibration: providedCalibration,
|
||||||
|
steeringMatrix,
|
||||||
|
output: providedOutput,
|
||||||
|
steeringReal: providedSteeringReal,
|
||||||
|
steeringImaginary: providedSteeringImaginary,
|
||||||
signal,
|
signal,
|
||||||
pointBatchSize,
|
pointBatchSize = 64,
|
||||||
} = options;
|
} = options;
|
||||||
const microphoneCount = arrayDefinition.microphones.length;
|
const microphoneCount = arrayDefinition.microphones.length;
|
||||||
const output = new Float32Array(pointCount);
|
const output = providedOutput ?? new Float32Array(pointCount);
|
||||||
const steeringReal = new Float64Array(microphoneCount);
|
const steeringReal = providedSteeringReal ?? new Float64Array(microphoneCount);
|
||||||
const steeringImaginary = new Float64Array(microphoneCount);
|
const steeringImaginary = providedSteeringImaginary ?? new Float64Array(microphoneCount);
|
||||||
const angularWaveNumber = (TAU * bin * sampleRate) / (fftSize * speedOfSound);
|
const angularWaveNumber = (TAU * bin * sampleRate) / (fftSize * speedOfSound);
|
||||||
const microphoneScale = 1 / microphoneCount;
|
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) {
|
for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const distanceOffset = pointIndex * microphoneCount;
|
const distanceOffset = pointIndex * microphoneCount;
|
||||||
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
|
const steeringOffset = steeringMatrix ? distanceOffset : 0;
|
||||||
const microphone = arrayDefinition.microphones[microphoneIndex];
|
if (!steeringMatrix) {
|
||||||
const calibrationPhase = ((microphone.phaseOffsetDeg ?? 0) * Math.PI) / 180;
|
fillSteeringVector({
|
||||||
const phase = angularWaveNumber * distances[distanceOffset + microphoneIndex] - calibrationPhase;
|
angularWaveNumber,
|
||||||
const calibrationGain = dbToLinear(-(microphone.sensitivityDb ?? 0));
|
distances,
|
||||||
steeringReal[microphoneIndex] = Math.cos(phase) * calibrationGain;
|
distanceOffset,
|
||||||
steeringImaginary[microphoneIndex] = Math.sin(phase) * calibrationGain;
|
calibration,
|
||||||
|
outputReal: steeringReal,
|
||||||
|
outputImaginary: steeringImaginary,
|
||||||
|
outputOffset: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let accumulatedPower = 0;
|
let accumulatedPower = 0;
|
||||||
@@ -73,8 +204,8 @@ export async function computeBinMap(options) {
|
|||||||
const spectrumIndex = spectrumOffset + microphoneIndex;
|
const spectrumIndex = spectrumOffset + microphoneIndex;
|
||||||
const inputReal = spectrum.real[spectrumIndex];
|
const inputReal = spectrum.real[spectrumIndex];
|
||||||
const inputImaginary = spectrum.imaginary[spectrumIndex];
|
const inputImaginary = spectrum.imaginary[spectrumIndex];
|
||||||
const phaseReal = steeringReal[microphoneIndex];
|
const phaseReal = phaseRealValues[steeringOffset + microphoneIndex];
|
||||||
const phaseImaginary = steeringImaginary[microphoneIndex];
|
const phaseImaginary = phaseImaginaryValues[steeringOffset + microphoneIndex];
|
||||||
sumReal += inputReal * phaseReal - inputImaginary * phaseImaginary;
|
sumReal += inputReal * phaseReal - inputImaginary * phaseImaginary;
|
||||||
sumImaginary += inputReal * phaseImaginary + inputImaginary * phaseReal;
|
sumImaginary += inputReal * phaseImaginary + inputImaginary * phaseReal;
|
||||||
}
|
}
|
||||||
@@ -86,11 +217,200 @@ export async function computeBinMap(options) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function estimateDasWorkload(options) {
|
/**
|
||||||
const { channels, sampleRate, arrayDefinition, experiment } = options;
|
* Cache geometry and optional steering matrices for repeated CPU DAS analyses.
|
||||||
const { fftSize, hopSize } = experiment.stft;
|
*
|
||||||
|
* 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 bandBins = experiment.frequencyBands.map((band) => binsForBand(band, sampleRate, fftSize));
|
||||||
const selectedBins = [...new Set(bandBins.flat())].sort((a, b) => a - b);
|
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 frameCount = getFrameCount(channels[0].length, fftSize, hopSize);
|
||||||
const microphoneCount = arrayDefinition.microphones.length;
|
const microphoneCount = arrayDefinition.microphones.length;
|
||||||
const pointCount = experiment.scan.columns * experiment.scan.rows;
|
const pointCount = experiment.scan.columns * experiment.scan.rows;
|
||||||
@@ -100,7 +420,11 @@ export function estimateDasWorkload(options) {
|
|||||||
const binMapBytes = pointCount * Float32Array.BYTES_PER_ELEMENT;
|
const binMapBytes = pointCount * Float32Array.BYTES_PER_ELEMENT;
|
||||||
const spectrumBytesPerBin = frameCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2;
|
const spectrumBytesPerBin = frameCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2;
|
||||||
const fftWorkspaceBytes = fftSize * Float64Array.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;
|
const complexMacCount = pointCount * microphoneCount * frameCount * selectedBins.length;
|
||||||
return {
|
return {
|
||||||
bandBins,
|
bandBins,
|
||||||
@@ -110,6 +434,7 @@ export function estimateDasWorkload(options) {
|
|||||||
pointCount,
|
pointCount,
|
||||||
distanceBytes,
|
distanceBytes,
|
||||||
outputBytes,
|
outputBytes,
|
||||||
|
steeringCacheBytes,
|
||||||
spectrumBytesPerBin,
|
spectrumBytesPerBin,
|
||||||
baseWorkingBytes,
|
baseWorkingBytes,
|
||||||
complexMacCount,
|
complexMacCount,
|
||||||
@@ -147,7 +472,7 @@ export function finalizeDasBands(rawMaps, workload, experiment) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function computeDasMap(options) {
|
async function computeDasMapInternal(options, analysisPlan) {
|
||||||
const {
|
const {
|
||||||
channels,
|
channels,
|
||||||
sampleRate,
|
sampleRate,
|
||||||
@@ -167,7 +492,13 @@ export async function computeDasMap(options) {
|
|||||||
throw new Error(`채널 수 ${channels.length}와 마이크 수 ${arrayDefinition.microphones.length}가 다릅니다.`);
|
throw new Error(`채널 수 ${channels.length}와 마이크 수 ${arrayDefinition.microphones.length}가 다릅니다.`);
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(pointBatchSize) || pointBatchSize < 1) throw new Error("pointBatchSize는 1 이상의 정수여야 합니다.");
|
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) {
|
if (workload.complexMacCount > maximumComplexMacs) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`CPU DAS 예상 복소 누산 ${workload.complexMacCount.toLocaleString()}회가 ` +
|
`CPU DAS 예상 복소 누산 ${workload.complexMacCount.toLocaleString()}회가 ` +
|
||||||
@@ -199,15 +530,10 @@ export async function computeDasMap(options) {
|
|||||||
`현재 한도 ${maximumFftButterflies.toLocaleString()}회를 넘습니다. duration, FFT 크기 또는 대역폭을 줄이세요.`,
|
`현재 한도 ${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 rawMaps = experiment.frequencyBands.map(() => new Float32Array(workload.pointCount));
|
||||||
const bandIndexesByBin = new Map();
|
const bandIndexesByBin =
|
||||||
workload.bandBins.forEach((bins, bandIndex) => {
|
analysisPlan?.bandIndexesByBin ?? createBandIndexLookup(workload.bandBins);
|
||||||
bins.forEach((bin) => {
|
|
||||||
if (!bandIndexesByBin.has(bin)) bandIndexesByBin.set(bin, []);
|
|
||||||
bandIndexesByBin.get(bin).push(bandIndex);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let completedBins = 0;
|
let completedBins = 0;
|
||||||
for (let batchStart = 0; batchStart < workload.selectedBins.length; batchStart += batchSize) {
|
for (let batchStart = 0; batchStart < workload.selectedBins.length; batchStart += batchSize) {
|
||||||
@@ -236,6 +562,17 @@ export async function computeDasMap(options) {
|
|||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
|
|
||||||
for (const bin of batchBins) {
|
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({
|
const binMap = await computeBinMap({
|
||||||
bin,
|
bin,
|
||||||
spectrum: stft.spectra.get(bin),
|
spectrum: stft.spectra.get(bin),
|
||||||
@@ -246,6 +583,13 @@ export async function computeDasMap(options) {
|
|||||||
sampleRate,
|
sampleRate,
|
||||||
fftSize: experiment.stft.fftSize,
|
fftSize: experiment.stft.fftSize,
|
||||||
speedOfSound: scene.speedOfSound,
|
speedOfSound: scene.speedOfSound,
|
||||||
|
compensateFrequencyResponse:
|
||||||
|
experiment.beamforming.compensateFrequencyResponse !== false,
|
||||||
|
calibration,
|
||||||
|
steeringMatrix,
|
||||||
|
output: analysisPlan?.binMap,
|
||||||
|
steeringReal: analysisPlan?.steeringReal,
|
||||||
|
steeringImaginary: analysisPlan?.steeringImaginary,
|
||||||
signal,
|
signal,
|
||||||
pointBatchSize,
|
pointBatchSize,
|
||||||
});
|
});
|
||||||
@@ -270,5 +614,33 @@ export async function computeDasMap(options) {
|
|||||||
workload,
|
workload,
|
||||||
fftButterflyCount,
|
fftButterflyCount,
|
||||||
bands,
|
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 { fftInPlace } from "./fft.js";
|
||||||
|
import { interpolateFrequencyResponse } from "./frequency-response.js";
|
||||||
import { TAU } from "./math.js";
|
import { TAU } from "./math.js";
|
||||||
|
|
||||||
export function nextPowerOfTwo(value) {
|
export function nextPowerOfTwo(value) {
|
||||||
@@ -21,25 +22,96 @@ export function createDelayWorkspace(fftSize) {
|
|||||||
return { real: new Float64Array(fftSize), imaginary: new Float64Array(fftSize) };
|
return { real: new Float64Array(fftSize), imaginary: new Float64Array(fftSize) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addFrequencyDomainDelay(options) {
|
export function addFrequencyDomainPaths(options) {
|
||||||
const { output, spectrum, delaySamples, gain = 1, phaseOffsetDeg = 0, workspace } = options;
|
const {
|
||||||
if (!(delaySamples >= 0) || !Number.isFinite(delaySamples)) throw new Error(`유효하지 않은 delay: ${delaySamples}`);
|
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) {
|
if (workspace.real.length !== spectrum.fftSize || workspace.imaginary.length !== spectrum.fftSize) {
|
||||||
throw new Error("fractional-delay workspace 크기가 spectrum과 다릅니다.");
|
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 size = spectrum.fftSize;
|
||||||
const calibrationPhase = (phaseOffsetDeg * Math.PI) / 180;
|
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) {
|
for (let bin = 0; bin < size; bin += 1) {
|
||||||
const signedBin = bin <= size / 2 ? bin : bin - size;
|
const signedBin = bin <= size / 2 ? bin : bin - size;
|
||||||
const phaseSign = signedBin === 0 ? 0 : signedBin > 0 ? 1 : -1;
|
const phaseSign = signedBin === 0 ? 0 : signedBin > 0 ? 1 : -1;
|
||||||
const phase = (-TAU * signedBin * delaySamples) / size + phaseSign * calibrationPhase;
|
let responseGain = 1;
|
||||||
const phaseReal = Math.cos(phase);
|
let responsePhase = 0;
|
||||||
const phaseImaginary = Math.sin(phase);
|
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 inputReal = spectrum.real[bin];
|
||||||
const inputImaginary = spectrum.imaginary[bin];
|
const inputImaginary = spectrum.imaginary[bin];
|
||||||
workspace.real[bin] = inputReal * phaseReal - inputImaginary * phaseImaginary;
|
workspace.real[bin] =
|
||||||
workspace.imaginary[bin] = inputReal * phaseImaginary + inputImaginary * phaseReal;
|
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
|
// 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);
|
fftInPlace(workspace.real, workspace.imaginary, true);
|
||||||
const outputLength = Math.min(output.length, workspace.real.length);
|
const outputLength = Math.min(output.length, workspace.real.length);
|
||||||
for (let sampleIndex = 0; sampleIndex < outputLength; sampleIndex += 1) {
|
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 "./das.js";
|
||||||
export * from "./evaluation.js";
|
export * from "./evaluation.js";
|
||||||
|
export * from "./environment.js";
|
||||||
export * from "./fft.js";
|
export * from "./fft.js";
|
||||||
|
export * from "./frequency-response.js";
|
||||||
export * from "./fractional-delay.js";
|
export * from "./fractional-delay.js";
|
||||||
export * from "./math.js";
|
export * from "./math.js";
|
||||||
export * from "./mixdown.js";
|
export * from "./mixdown.js";
|
||||||
export * from "./noise.js";
|
export * from "./noise.js";
|
||||||
export * from "./propagation.js";
|
export * from "./propagation.js";
|
||||||
|
export * from "./realtime-localizer.js";
|
||||||
export * from "./signal.js";
|
export * from "./signal.js";
|
||||||
export * from "./stft.js";
|
export * from "./stft.js";
|
||||||
export * from "./trajectory.js";
|
export * from "./trajectory.js";
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { dbToLinear, distance3 } from "./math.js";
|
import { dbToLinear, distance3 } from "./math.js";
|
||||||
import {
|
import {
|
||||||
addFrequencyDomainDelay,
|
addFrequencyDomainDelay,
|
||||||
|
addFrequencyDomainPaths,
|
||||||
createDelayWorkspace,
|
createDelayWorkspace,
|
||||||
nextPowerOfTwo,
|
nextPowerOfTwo,
|
||||||
prepareDelaySpectrum,
|
prepareDelaySpectrum,
|
||||||
} from "./fractional-delay.js";
|
} from "./fractional-delay.js";
|
||||||
|
import { buildAcousticPaths, maximumAcousticDelaySeconds } from "./environment.js";
|
||||||
import { renderSignal } from "./signal.js";
|
import { renderSignal } from "./signal.js";
|
||||||
|
|
||||||
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
|
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
|
||||||
@@ -27,15 +29,13 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
} = options;
|
} = options;
|
||||||
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
||||||
const retainedBufferCount = arrayDefinition.microphones.length + scene.sources.length + 1;
|
const retainedBufferCount = arrayDefinition.microphones.length + scene.sources.length + 1;
|
||||||
let maximumDelaySamples = 0;
|
const maximumDelaySamples =
|
||||||
for (const source of scene.sources) {
|
maximumAcousticDelaySeconds({
|
||||||
for (const microphone of arrayDefinition.microphones) {
|
scene,
|
||||||
maximumDelaySamples = Math.max(
|
arrayDefinition,
|
||||||
maximumDelaySamples,
|
referenceDistance,
|
||||||
(distance3(source.position, microphone.position) / scene.speedOfSound) * scene.sampleRate,
|
minimumDistance,
|
||||||
);
|
}) * scene.sampleRate;
|
||||||
}
|
|
||||||
}
|
|
||||||
const delayFftSize = nextPowerOfTwo(sampleCount + Math.ceil(maximumDelaySamples) + 2);
|
const delayFftSize = nextPowerOfTwo(sampleCount + Math.ceil(maximumDelaySamples) + 2);
|
||||||
const delayWorkingBytes = delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4;
|
const delayWorkingBytes = delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4;
|
||||||
const estimatedBufferBytes =
|
const estimatedBufferBytes =
|
||||||
@@ -56,22 +56,49 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const acousticPathCounts = {};
|
||||||
|
let acousticPathCount = 0;
|
||||||
renderedSources.forEach(({ definition, samples }) => {
|
renderedSources.forEach(({ definition, samples }) => {
|
||||||
const distances = arrayDefinition.microphones.map((microphone) => distance3(definition.position, microphone.position));
|
const microphonePaths = arrayDefinition.microphones.map((microphone) =>
|
||||||
const maximumDelaySamples = (Math.max(...distances) / scene.speedOfSound) * scene.sampleRate;
|
buildAcousticPaths({
|
||||||
const spectrum = prepareDelaySpectrum(samples, maximumDelaySamples);
|
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);
|
const workspace = createDelayWorkspace(spectrum.fftSize);
|
||||||
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
|
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
|
||||||
const distance = distances[microphoneIndex];
|
const paths = microphonePaths[microphoneIndex];
|
||||||
const delaySamples = (distance / scene.speedOfSound) * scene.sampleRate;
|
for (const path of paths) {
|
||||||
|
acousticPathCount += 1;
|
||||||
|
acousticPathCounts[path.kind] = (acousticPathCounts[path.kind] ?? 0) + 1;
|
||||||
|
}
|
||||||
const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0);
|
const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0);
|
||||||
const amplitude = sphericalAttenuation(distance, referenceDistance, minimumDistance) * sensitivity;
|
addFrequencyDomainPaths({
|
||||||
addFrequencyDomainDelay({
|
|
||||||
output: channels[microphoneIndex],
|
output: channels[microphoneIndex],
|
||||||
spectrum,
|
spectrum,
|
||||||
delaySamples,
|
paths: paths.map((path) => ({
|
||||||
gain: amplitude,
|
delaySamples: path.delaySeconds * scene.sampleRate,
|
||||||
|
gain: path.gain,
|
||||||
|
})),
|
||||||
|
gain: sensitivity,
|
||||||
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
||||||
|
frequencyResponse: microphone.frequencyResponse,
|
||||||
|
sampleRate: scene.sampleRate,
|
||||||
workspace,
|
workspace,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -93,6 +120,8 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
delaySamples: 0,
|
delaySamples: 0,
|
||||||
gain: dbToLinear(microphone.sensitivityDb ?? 0),
|
gain: dbToLinear(microphone.sensitivityDb ?? 0),
|
||||||
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
||||||
|
frequencyResponse: microphone.frequencyResponse,
|
||||||
|
sampleRate: scene.sampleRate,
|
||||||
workspace: createDelayWorkspace(spectrum.fftSize),
|
workspace: createDelayWorkspace(spectrum.fftSize),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -127,5 +156,8 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
appliedScale,
|
appliedScale,
|
||||||
clippedSampleCount,
|
clippedSampleCount,
|
||||||
estimatedBufferBytes,
|
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_MICROPHONES = 1024;
|
||||||
const MAX_SOURCES = 32;
|
const MAX_SOURCES = 32;
|
||||||
const MAX_GRID_SIDE = 512;
|
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 */
|
/** @typedef {{ path: string, message: string }} ValidationIssue */
|
||||||
|
|
||||||
@@ -65,6 +67,28 @@ function requirePosition(value, path, issues) {
|
|||||||
return true;
|
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) {
|
function validateHeader(value, expectedKind, issues) {
|
||||||
if (!requireRecord(value, "$", issues)) return false;
|
if (!requireRecord(value, "$", issues)) return false;
|
||||||
if (value.schemaVersion !== SCHEMA_VERSION) {
|
if (value.schemaVersion !== SCHEMA_VERSION) {
|
||||||
@@ -131,6 +155,37 @@ function validateArrayRelationships(value) {
|
|||||||
if (microphone.sensitivityDb !== undefined) {
|
if (microphone.sensitivityDb !== undefined) {
|
||||||
requireFinite(microphone.sensitivityDb, `${path}.sensitivityDb`, issues, { min: -120, max: 120 });
|
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)) {
|
for (const duplicate of findDuplicates(ids)) {
|
||||||
addIssue(issues, "$.microphones", `중복 마이크 id: ${duplicate}`);
|
addIssue(issues, "$.microphones", `중복 마이크 id: ${duplicate}`);
|
||||||
@@ -178,6 +233,44 @@ function validateSceneRelationships(value) {
|
|||||||
requireFinite(value.speedOfSound, "$.speedOfSound", issues, { min: 250, max: 400 });
|
requireFinite(value.speedOfSound, "$.speedOfSound", issues, { min: 250, max: 400 });
|
||||||
requireInteger(value.seed, "$.seed", issues, { min: 0, max: 0xffffffff });
|
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 assets = Array.isArray(value.assets) ? value.assets : [];
|
||||||
const assetsById = new Map();
|
const assetsById = new Map();
|
||||||
const allIds = [];
|
const allIds = [];
|
||||||
@@ -231,7 +324,10 @@ function validateSceneRelationships(value) {
|
|||||||
if (!requireRecord(source, path, issues)) return;
|
if (!requireRecord(source, path, issues)) return;
|
||||||
if (requireString(source.id, `${path}.id`, issues)) sourceIds.push(source.id);
|
if (requireString(source.id, `${path}.id`, issues)) sourceIds.push(source.id);
|
||||||
requireString(source.name, `${path}.name`, issues);
|
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)) {
|
if (Array.isArray(source.trajectory)) {
|
||||||
let previousTime = -Infinity;
|
let previousTime = -Infinity;
|
||||||
source.trajectory.forEach((keyframe, keyframeIndex) => {
|
source.trajectory.forEach((keyframe, keyframeIndex) => {
|
||||||
@@ -241,7 +337,10 @@ function validateSceneRelationships(value) {
|
|||||||
min: 0,
|
min: 0,
|
||||||
max: sceneDurationValid ? value.duration : 300,
|
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) {
|
if (timeValid && keyframe.time <= previousTime) {
|
||||||
addIssue(issues, `${keyframePath}.time`, "trajectory time은 앞 keyframe보다 커야 합니다.");
|
addIssue(issues, `${keyframePath}.time`, "trajectory time은 앞 keyframe보다 커야 합니다.");
|
||||||
}
|
}
|
||||||
@@ -346,6 +445,16 @@ function validateExperimentRelationships(value, context = {}) {
|
|||||||
max: 10,
|
max: 10,
|
||||||
exclusiveMin: true,
|
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)) {
|
if (value.evaluation !== undefined && requireRecord(value.evaluation, "$.evaluation", issues)) {
|
||||||
const threshold = value.evaluation.detectionThresholdRatio;
|
const threshold = value.evaluation.detectionThresholdRatio;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
estimateDasWorkload,
|
estimateDasWorkload,
|
||||||
finalizeDasBands,
|
finalizeDasBands,
|
||||||
} from "../acoustics-core/das.js";
|
} 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 { extractStftBins } from "../acoustics-core/stft.js";
|
||||||
import {
|
import {
|
||||||
DAS_PARAMS_BYTE_LENGTH,
|
DAS_PARAMS_BYTE_LENGTH,
|
||||||
@@ -66,13 +68,45 @@ function minimumBufferSize(byteLength) {
|
|||||||
return Math.max(4, Math.ceil(byteLength / 4) * 4);
|
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)) {
|
if (!stft || !Number.isInteger(stft.frameCount) || !Number.isInteger(stft.microphoneCount)) {
|
||||||
throw new TypeError("유효한 STFT 결과가 필요합니다.");
|
throw new TypeError("유효한 STFT 결과가 필요합니다.");
|
||||||
}
|
}
|
||||||
if (!Array.isArray(bins) || bins.length === 0) throw new TypeError("하나 이상의 FFT bin이 필요합니다.");
|
if (!Array.isArray(bins) || bins.length === 0) throw new TypeError("하나 이상의 FFT bin이 필요합니다.");
|
||||||
const valuesPerBin = stft.frameCount * stft.microphoneCount;
|
const valuesPerBin = stft.frameCount * stft.microphoneCount;
|
||||||
const packed = new Float32Array(bins.length * valuesPerBin * 2);
|
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) => {
|
bins.forEach((bin, binIndex) => {
|
||||||
const spectrum = stft.spectra.get(bin);
|
const spectrum = stft.spectra.get(bin);
|
||||||
if (!spectrum || spectrum.real.length !== valuesPerBin || spectrum.imaginary.length !== valuesPerBin) {
|
if (!spectrum || spectrum.real.length !== valuesPerBin || spectrum.imaginary.length !== valuesPerBin) {
|
||||||
@@ -80,8 +114,21 @@ export function packStftBatch(stft, bins) {
|
|||||||
}
|
}
|
||||||
const binOffset = binIndex * valuesPerBin * 2;
|
const binOffset = binIndex * valuesPerBin * 2;
|
||||||
for (let index = 0; index < valuesPerBin; index += 1) {
|
for (let index = 0; index < valuesPerBin; index += 1) {
|
||||||
|
if (!inverseResponses) {
|
||||||
packed[binOffset + index * 2] = spectrum.real[index];
|
packed[binOffset + index * 2] = spectrum.real[index];
|
||||||
packed[binOffset + index * 2 + 1] = spectrum.imaginary[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;
|
return packed;
|
||||||
@@ -488,7 +535,13 @@ export async function computeDasWebGpu(options) {
|
|||||||
spectrumBatchCount: plan.binBatchCount,
|
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;
|
stftMs += now() - stftStartedAt;
|
||||||
const binIndices = Uint32Array.from(batchBins);
|
const binIndices = Uint32Array.from(batchBins);
|
||||||
const binResources = [];
|
const binResources = [];
|
||||||
|
|||||||
+11
-3
@@ -4,9 +4,17 @@
|
|||||||
|
|
||||||
JSON Schema Draft 2020-12 형식입니다.
|
JSON Schema Draft 2020-12 형식입니다.
|
||||||
|
|
||||||
- `array.schema.json`: 배열 크기, 좌표계, 마이크 위치와 선택적 보정값
|
- `array.schema.json`: 배열 크기, 좌표계, 마이크 위치와 선택적 scalar/주파수 응답 보정값
|
||||||
- `scene.schema.json`: 재현 가능한 음원 recipe, asset 참조, 정적 위치와 선택적 trajectory
|
- `scene.schema.json`: 음원 recipe, asset·trajectory와 선택적 sparse shoebox 음향 환경
|
||||||
- `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT/DAS와 peak 검출 임계값
|
- `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT/DAS와 peak 검출 임계값
|
||||||
- `result.schema.json`: raw/display/후처리 map, 복수 peak, 비확률 beam spread, timing과 평가 지표
|
- `result.schema.json`: raw/display/후처리 map, 복수 peak, 비확률 beam spread, timing과 평가 지표
|
||||||
|
|
||||||
중복 id, Nyquist, 시간 구간과 참조 무결성은 `packages/contracts/validation.js`에서 추가로 검증합니다.
|
마이크 주파수 응답점은 배열 계약 자체이므로 특정 Scene의 Nyquist와 결합하지 않고 0~1 MHz의
|
||||||
|
안전 범위에서 엄격한 오름차순을 요구합니다. 실제 분석에서는 선택한 Scene/STFT의 bin 주파수만
|
||||||
|
보간합니다. 중복 id, Nyquist, 시간 구간과 참조 무결성은
|
||||||
|
`packages/contracts/validation.js`에서 추가로 검증합니다.
|
||||||
|
|
||||||
|
Scene `acoustics`는 기본 `free-field` 또는 `shoebox`입니다. shoebox는 room bounds, 0/1차
|
||||||
|
반사와 scattering 비율, bounded diffuse tail, 최대 8개의 AABB occluder를 저장합니다.
|
||||||
|
room·occluder의 각 축은 `min < max`여야 하고 source/trajectory와 occluder는 room 안에
|
||||||
|
있어야 합니다. 이 계약은 전체 파동 해석이 아니라 sparse path 근사의 입력입니다.
|
||||||
|
|||||||
@@ -42,7 +42,20 @@
|
|||||||
"id": { "type": "string", "minLength": 1, "maxLength": 64 },
|
"id": { "type": "string", "minLength": 1, "maxLength": 64 },
|
||||||
"position": { "$ref": "#/$defs/position" },
|
"position": { "$ref": "#/$defs/position" },
|
||||||
"sensitivityDb": { "type": "number", "minimum": -120, "maximum": 120 },
|
"sensitivityDb": { "type": "number", "minimum": -120, "maximum": 120 },
|
||||||
"phaseOffsetDeg": { "type": "number", "minimum": -180, "maximum": 180 }
|
"phaseOffsetDeg": { "type": "number", "minimum": -180, "maximum": 180 },
|
||||||
|
"frequencyResponse": {
|
||||||
|
"type": "array", "minItems": 2, "maxItems": 64,
|
||||||
|
"items": { "$ref": "#/$defs/frequencyResponsePoint" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"frequencyResponsePoint": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["frequencyHz", "gainDb"],
|
||||||
|
"properties": {
|
||||||
|
"frequencyHz": { "type": "number", "minimum": 0, "maximum": 1000000 },
|
||||||
|
"gainDb": { "type": "number", "minimum": -120, "maximum": 120 },
|
||||||
|
"phaseDeg": { "type": "number", "minimum": -180, "maximum": 180 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,8 @@
|
|||||||
"algorithm": { "const": "das" }, "backend": { "enum": ["auto", "cpu", "webgpu"] },
|
"algorithm": { "const": "das" }, "backend": { "enum": ["auto", "cpu", "webgpu"] },
|
||||||
"minimumDistance": { "type": "number", "exclusiveMinimum": 0, "maximum": 10 },
|
"minimumDistance": { "type": "number", "exclusiveMinimum": 0, "maximum": 10 },
|
||||||
"normalization": { "enum": ["peak", "none"] },
|
"normalization": { "enum": ["peak", "none"] },
|
||||||
"dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 }
|
"dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 },
|
||||||
|
"compensateFrequencyResponse": { "type": "boolean" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"evaluation": {
|
"evaluation": {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"targetPeak": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }
|
"targetPeak": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"acoustics": { "$ref": "#/$defs/acoustics" },
|
||||||
"image": { "$ref": "#/$defs/image" },
|
"image": { "$ref": "#/$defs/image" },
|
||||||
"assets": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/asset" } },
|
"assets": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/asset" } },
|
||||||
"sources": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/source" } },
|
"sources": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/source" } },
|
||||||
@@ -38,6 +39,67 @@
|
|||||||
"position": { "$ref": "#/$defs/position" }
|
"position": { "$ref": "#/$defs/position" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"wallLevels": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"xMin": { "type": "number", "minimum": -60, "maximum": 0 },
|
||||||
|
"xMax": { "type": "number", "minimum": -60, "maximum": 0 },
|
||||||
|
"yMin": { "type": "number", "minimum": -60, "maximum": 0 },
|
||||||
|
"yMax": { "type": "number", "minimum": -60, "maximum": 0 },
|
||||||
|
"zMin": { "type": "number", "minimum": -60, "maximum": 0 },
|
||||||
|
"zMax": { "type": "number", "minimum": -60, "maximum": 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"room": {
|
||||||
|
"type": "object", "additionalProperties": false, "required": ["min", "max"],
|
||||||
|
"properties": {
|
||||||
|
"min": { "$ref": "#/$defs/position" },
|
||||||
|
"max": { "$ref": "#/$defs/position" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reflections": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["order", "wallGainDb", "scattering"],
|
||||||
|
"properties": {
|
||||||
|
"order": { "enum": [0, 1] },
|
||||||
|
"wallGainDb": { "type": "number", "minimum": -60, "maximum": 0 },
|
||||||
|
"perWallDb": { "$ref": "#/$defs/wallLevels" },
|
||||||
|
"scattering": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"diffuseReverb": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["enabled", "rt60Seconds", "gainDb", "tapCount"],
|
||||||
|
"properties": {
|
||||||
|
"enabled": { "type": "boolean" },
|
||||||
|
"rt60Seconds": { "type": "number", "minimum": 0.05, "maximum": 5 },
|
||||||
|
"gainDb": { "type": "number", "minimum": -60, "maximum": 0 },
|
||||||
|
"tapCount": { "type": "integer", "minimum": 1, "maximum": 32 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"occluder": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["id", "min", "max", "transmissionDb", "diffractionGainDb"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
|
||||||
|
"min": { "$ref": "#/$defs/position" },
|
||||||
|
"max": { "$ref": "#/$defs/position" },
|
||||||
|
"transmissionDb": { "type": "number", "minimum": -120, "maximum": 0 },
|
||||||
|
"diffractionGainDb": { "type": "number", "minimum": -120, "maximum": 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"acoustics": {
|
||||||
|
"type": "object", "additionalProperties": false, "required": ["model"],
|
||||||
|
"properties": {
|
||||||
|
"model": { "enum": ["free-field", "shoebox"] },
|
||||||
|
"room": { "$ref": "#/$defs/room" },
|
||||||
|
"reflections": { "$ref": "#/$defs/reflections" },
|
||||||
|
"diffuseReverb": { "$ref": "#/$defs/diffuseReverb" },
|
||||||
|
"occluders": {
|
||||||
|
"type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/occluder" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"type": "object", "additionalProperties": false,
|
"type": "object", "additionalProperties": false,
|
||||||
"required": ["assetId", "widthPixels", "heightPixels", "plane"],
|
"required": ["assetId", "widthPixels", "heightPixels", "plane"],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ globalThis.assertEqual = (actual, expected, message = "values differ") => {
|
|||||||
try {
|
try {
|
||||||
await import("/tests/unit/contracts.test.js");
|
await import("/tests/unit/contracts.test.js");
|
||||||
await import("/tests/unit/acoustics.test.js");
|
await import("/tests/unit/acoustics.test.js");
|
||||||
|
await import("/tests/unit/realtime-localizer.test.js");
|
||||||
await import("/tests/unit/builder.test.js");
|
await import("/tests/unit/builder.test.js");
|
||||||
await import("/tests/unit/evaluation.test.js");
|
await import("/tests/unit/evaluation.test.js");
|
||||||
await import("/tests/unit/camera.test.js");
|
await import("/tests/unit/camera.test.js");
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩
|
|||||||
- `contracts.test.js`: Array·Scene·Experiment·Result schema, 관계 검증, 안전한 asset 경로와 이동 fixture
|
- `contracts.test.js`: Array·Scene·Experiment·Result schema, 관계 검증, 안전한 asset 경로와 이동 fixture
|
||||||
- `acoustics.test.js`: seed 재현성, 자유음장 전파, trajectory 보간, sample 정렬 시간창과
|
- `acoustics.test.js`: seed 재현성, 자유음장 전파, trajectory 보간, sample 정렬 시간창과
|
||||||
tone/noise/asset 절대 시간 연속성, calibration, STFT와 CPU DAS
|
tone/noise/asset 절대 시간 연속성, calibration, STFT와 CPU DAS
|
||||||
|
- `realtime-localizer.test.js`: 기존 CPU DAS와 bit-exact map 동등성, chunk 경계 독립성,
|
||||||
|
bounded ring/result batch, 취소와 geometry·steering cache 연산 절감
|
||||||
- `builder.test.js`: Builder scene model, source/asset·trajectory 조작, 오디오 크기와 WAV 사전 검증
|
- `builder.test.js`: Builder scene model, source/asset·trajectory 조작, 오디오 크기와 WAV 사전 검증
|
||||||
- `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적
|
- `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적
|
||||||
beam-spread covariance ellipse
|
beam-spread covariance ellipse
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { computeBinMap, computeDasMap, createDistanceMatrix } from "/packages/acoustics-core/das.js";
|
import { computeBinMap, computeDasMap, createDistanceMatrix } from "/packages/acoustics-core/das.js";
|
||||||
import { fftInPlace, fftReal } from "/packages/acoustics-core/fft.js";
|
import { fftInPlace, fftReal } from "/packages/acoustics-core/fft.js";
|
||||||
|
import {
|
||||||
|
buildAcousticPaths,
|
||||||
|
maximumAcousticDelaySeconds,
|
||||||
|
} from "/packages/acoustics-core/environment.js";
|
||||||
|
import { interpolateFrequencyResponse } from "/packages/acoustics-core/frequency-response.js";
|
||||||
import {
|
import {
|
||||||
addFrequencyDomainDelay,
|
addFrequencyDomainDelay,
|
||||||
|
addFrequencyDomainPaths,
|
||||||
createDelayWorkspace,
|
createDelayWorkspace,
|
||||||
prepareDelaySpectrum,
|
prepareDelaySpectrum,
|
||||||
} from "/packages/acoustics-core/fractional-delay.js";
|
} from "/packages/acoustics-core/fractional-delay.js";
|
||||||
@@ -119,6 +125,100 @@ test("거리, dB와 구면 감쇠 기본식을 계산한다", () => {
|
|||||||
assertNear(sphericalAttenuation(2), 0.5, 1e-12, "1/r attenuation");
|
assertNear(sphericalAttenuation(2), 0.5, 1e-12, "1/r attenuation");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("자유음장 경로 계약은 기존 직접음 delay와 1/r gain을 보존한다", () => {
|
||||||
|
const scene = { speedOfSound: 400, seed: 11 };
|
||||||
|
const paths = buildAcousticPaths({
|
||||||
|
scene,
|
||||||
|
sourcePosition: [0, 0, 2],
|
||||||
|
receiverPosition: [0, 0, 0],
|
||||||
|
sourceId: "source",
|
||||||
|
receiverId: "mic",
|
||||||
|
});
|
||||||
|
assertEqual(paths.length, 1, "direct path only");
|
||||||
|
assertEqual(paths[0].kind, "direct", "path kind");
|
||||||
|
assertNear(paths[0].delaySeconds, 0.005, 1e-12, "direct delay");
|
||||||
|
assertNear(paths[0].gain, 0.5, 1e-12, "direct spherical gain");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shoebox가 1차 반사와 산란을 고정된 sparse path로 만든다", () => {
|
||||||
|
const scene = {
|
||||||
|
speedOfSound: 343,
|
||||||
|
seed: 23,
|
||||||
|
sources: [{ id: "source", position: [0, 0, 1] }],
|
||||||
|
acoustics: {
|
||||||
|
model: "shoebox",
|
||||||
|
room: { min: [-2, -2, -1], max: [2, 2, 3] },
|
||||||
|
reflections: { order: 1, wallGainDb: -6, scattering: 0.25 },
|
||||||
|
diffuseReverb: { enabled: true, rt60Seconds: 0.2, gainDb: -18, tapCount: 4 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const options = {
|
||||||
|
scene,
|
||||||
|
sourcePosition: scene.sources[0].position,
|
||||||
|
receiverPosition: [0, 0, 0],
|
||||||
|
sourceId: "source",
|
||||||
|
receiverId: "mic",
|
||||||
|
};
|
||||||
|
const first = buildAcousticPaths(options);
|
||||||
|
const second = buildAcousticPaths(options);
|
||||||
|
assertEqual(first.length, 17, "direct + six reflection + six scatter + four diffuse");
|
||||||
|
assertEqual(first.filter(({ kind }) => kind === "reflection").length, 6, "first-order reflection count");
|
||||||
|
assertEqual(first.filter(({ kind }) => kind === "scattering").length, 6, "scattering count");
|
||||||
|
assertEqual(first.filter(({ kind }) => kind === "diffuse").length, 4, "diffuse count");
|
||||||
|
assertEqual(JSON.stringify(first), JSON.stringify(second), "seeded path set is deterministic");
|
||||||
|
const maximum = maximumAcousticDelaySeconds({
|
||||||
|
scene,
|
||||||
|
arrayDefinition: { microphones: [{ id: "mic", position: [0, 0, 0] }] },
|
||||||
|
});
|
||||||
|
assertNear(maximum, Math.max(...first.map(({ delaySeconds }) => delaySeconds)), 1e-12, "maximum delay");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("차폐물은 직접음을 감쇠하고 더 긴 edge diffraction 경로를 추가한다", () => {
|
||||||
|
const scene = {
|
||||||
|
speedOfSound: 400,
|
||||||
|
seed: 1,
|
||||||
|
acoustics: {
|
||||||
|
model: "shoebox",
|
||||||
|
room: { min: [-2, -2, -2], max: [2, 2, 2] },
|
||||||
|
occluders: [{
|
||||||
|
id: "panel",
|
||||||
|
min: [-0.1, -0.5, -0.5],
|
||||||
|
max: [0.1, 0.5, 0.5],
|
||||||
|
transmissionDb: -20,
|
||||||
|
diffractionGainDb: -12,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const paths = buildAcousticPaths({
|
||||||
|
scene,
|
||||||
|
sourcePosition: [-1, 0, 0],
|
||||||
|
receiverPosition: [1, 0, 0],
|
||||||
|
});
|
||||||
|
assertEqual(paths.length, 2, "direct and diffraction paths");
|
||||||
|
const direct = paths.find(({ kind }) => kind === "direct");
|
||||||
|
const diffraction = paths.find(({ kind }) => kind === "diffraction");
|
||||||
|
assertNear(direct.gain, 0.05, 1e-12, "-20 dB direct transmission");
|
||||||
|
assert(diffraction.distance > direct.distance, "edge detour is longer than line of sight");
|
||||||
|
assert(diffraction.delaySeconds > direct.delaySeconds, "diffraction arrives later");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("마이크 주파수 응답을 dB와 wrapped phase로 보간하고 끝점 밖에서 고정한다", () => {
|
||||||
|
const curve = [
|
||||||
|
{ frequencyHz: 100, gainDb: -6, phaseDeg: 170 },
|
||||||
|
{ frequencyHz: 300, gainDb: 6, phaseDeg: -170 },
|
||||||
|
];
|
||||||
|
const below = interpolateFrequencyResponse(curve, 20);
|
||||||
|
const middle = interpolateFrequencyResponse(curve, 200);
|
||||||
|
const above = interpolateFrequencyResponse(curve, 10_000);
|
||||||
|
assertEqual(below.gainDb, -6, "lower endpoint gain");
|
||||||
|
assertEqual(below.phaseDeg, 170, "lower endpoint phase");
|
||||||
|
assertNear(middle.gainDb, 0, 1e-12, "interpolated dB");
|
||||||
|
assertNear(Math.abs(middle.phaseDeg), 180, 1e-12, "phase follows shortest wrapped path");
|
||||||
|
assertNear(middle.linearGain, 1, 1e-12, "interpolated linear gain");
|
||||||
|
assertEqual(above.gainDb, 6, "upper endpoint gain");
|
||||||
|
assertEqual(above.phaseDeg, -170, "upper endpoint phase");
|
||||||
|
});
|
||||||
|
|
||||||
test("index 기반 noise가 seed에 대해 결정적이다", () => {
|
test("index 기반 noise가 seed에 대해 결정적이다", () => {
|
||||||
const first = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
|
const first = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
|
||||||
const second = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
|
const second = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
|
||||||
@@ -174,6 +274,24 @@ test("FFT phase shift가 integer와 fractional delay를 적용한다", () => {
|
|||||||
assert(Math.sqrt(squaredError / count) < 1e-3, "fractional delay RMS error");
|
assert(Math.sqrt(squaredError / count) < 1e-3, "fractional delay RMS error");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("여러 음향 경로를 하나의 frequency-domain transfer로 합성한다", () => {
|
||||||
|
const impulse = new Float32Array(16);
|
||||||
|
impulse[2] = 1;
|
||||||
|
const spectrum = prepareDelaySpectrum(impulse, 5);
|
||||||
|
const output = new Float32Array(16);
|
||||||
|
addFrequencyDomainPaths({
|
||||||
|
output,
|
||||||
|
spectrum,
|
||||||
|
paths: [
|
||||||
|
{ delaySamples: 3, gain: 1 },
|
||||||
|
{ delaySamples: 5, gain: 0.5 },
|
||||||
|
],
|
||||||
|
workspace: createDelayWorkspace(spectrum.fftSize),
|
||||||
|
});
|
||||||
|
assertNear(output[5], 1, 1e-6, "direct impulse");
|
||||||
|
assertNear(output[7], 0.5, 1e-6, "reflected impulse");
|
||||||
|
});
|
||||||
|
|
||||||
test("STFT frame count가 마지막 partial tail을 포함한다", () => {
|
test("STFT frame count가 마지막 partial tail을 포함한다", () => {
|
||||||
assertEqual(getFrameCount(512, 512, 256), 1);
|
assertEqual(getFrameCount(512, 512, 256), 1);
|
||||||
assertEqual(getFrameCount(513, 512, 256), 2);
|
assertEqual(getFrameCount(513, 512, 256), 2);
|
||||||
@@ -207,6 +325,35 @@ test("구면 전파가 거리별 지연과 1/r 진폭비를 적용한다", () =>
|
|||||||
assertNear(rms(result.channels[0], 16, 150) / rms(result.channels[1], 16, 150), 2, 0.02, "distance amplitude ratio");
|
assertNear(rms(result.channels[0], 16, 150) / rms(result.channels[1], 16, 150), 2, 0.02, "distance amplitude ratio");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("microphone synthesis가 shoebox sparse path를 실제 채널에 적용한다", () => {
|
||||||
|
const scene = {
|
||||||
|
sampleRate: 8000,
|
||||||
|
duration: 0.02,
|
||||||
|
speedOfSound: 343,
|
||||||
|
seed: 7,
|
||||||
|
mix: { normalization: "none", targetPeak: 0.95 },
|
||||||
|
acoustics: {
|
||||||
|
model: "shoebox",
|
||||||
|
room: { min: [-2, -2, -1], max: [2, 2, 3] },
|
||||||
|
reflections: { order: 1, wallGainDb: -12, scattering: 0 },
|
||||||
|
},
|
||||||
|
sources: [{
|
||||||
|
id: "tone", name: "tone", position: [0, 0, 1], gainDb: -30,
|
||||||
|
startTime: 0, duration: 0.02,
|
||||||
|
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
|
||||||
|
}],
|
||||||
|
ambient: [],
|
||||||
|
};
|
||||||
|
const result = synthesizeMicrophoneSignals({
|
||||||
|
scene,
|
||||||
|
arrayDefinition: { microphones: [{ id: "mic", position: [0, 0, 0] }] },
|
||||||
|
});
|
||||||
|
assertEqual(result.acousticModel, "shoebox", "reported acoustic model");
|
||||||
|
assertEqual(result.acousticPathCount, 7, "one direct and six image-source paths");
|
||||||
|
assertEqual(result.acousticPathCounts.reflection, 6, "reflection path count");
|
||||||
|
assert(result.preNormalizationPeak > 0, "multipath channel contains signal");
|
||||||
|
});
|
||||||
|
|
||||||
test("peak mix 정규화와 channel buffer 한도를 적용한다", () => {
|
test("peak mix 정규화와 channel buffer 한도를 적용한다", () => {
|
||||||
const scene = {
|
const scene = {
|
||||||
sampleRate: 8000,
|
sampleRate: 8000,
|
||||||
@@ -251,11 +398,65 @@ test("ambient에도 microphone gain과 phase calibration 편차를 적용한다"
|
|||||||
assertNear(result.channels[0][80], expected, 4e-3, "ambient calibrated sample");
|
assertNear(result.channels[0][80], expected, 4e-3, "ambient calibrated sample");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("합성 microphone channel에 주파수별 응답 gain을 적용한다", () => {
|
||||||
|
const scene = {
|
||||||
|
sampleRate: 8000,
|
||||||
|
duration: 0.256,
|
||||||
|
speedOfSound: 343,
|
||||||
|
seed: 1,
|
||||||
|
mix: { normalization: "none", targetPeak: 0.95 },
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "lower", name: "lower", position: [0, 0, 0], gainDb: -30, startTime: 0, duration: 0.256,
|
||||||
|
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "upper", name: "upper", position: [0, 0, 0], gainDb: -30, startTime: 0, duration: 0.256,
|
||||||
|
signal: { type: "tone", frequency: 1000, phaseDeg: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ambient: [],
|
||||||
|
};
|
||||||
|
const baselineArray = { microphones: [{ id: "mic", position: [0, 0, 0] }] };
|
||||||
|
const responseArray = {
|
||||||
|
microphones: [{
|
||||||
|
id: "mic",
|
||||||
|
position: [0, 0, 0],
|
||||||
|
frequencyResponse: [
|
||||||
|
{ frequencyHz: 500, gainDb: -6.020599913 },
|
||||||
|
{ frequencyHz: 1000, gainDb: 6.020599913 },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const baseline = fftReal(synthesizeMicrophoneSignals({ scene, arrayDefinition: baselineArray }).channels[0]);
|
||||||
|
const filtered = fftReal(synthesizeMicrophoneSignals({ scene, arrayDefinition: responseArray }).channels[0]);
|
||||||
|
const magnitudeAt = (spectrum, frequency) => {
|
||||||
|
const bin = Math.round((frequency * spectrum.real.length) / scene.sampleRate);
|
||||||
|
return Math.hypot(spectrum.real[bin], spectrum.imaginary[bin]);
|
||||||
|
};
|
||||||
|
assertNear(
|
||||||
|
magnitudeAt(filtered, 500) / magnitudeAt(baseline, 500),
|
||||||
|
0.5,
|
||||||
|
0.015,
|
||||||
|
"500 Hz channel response",
|
||||||
|
);
|
||||||
|
assertNear(
|
||||||
|
magnitudeAt(filtered, 1000) / magnitudeAt(baseline, 1000),
|
||||||
|
2,
|
||||||
|
0.03,
|
||||||
|
"1 kHz channel response",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("CPU DAS peak가 calibration 후 단일 tone의 실제 grid 위치를 찾는다", async () => {
|
test("CPU DAS peak가 calibration 후 단일 tone의 실제 grid 위치를 찾는다", async () => {
|
||||||
const arrayDefinition = createArray(4, 4, 0.3, 0.3);
|
const arrayDefinition = createArray(4, 4, 0.3, 0.3);
|
||||||
arrayDefinition.microphones.forEach((microphone, index) => {
|
arrayDefinition.microphones.forEach((microphone, index) => {
|
||||||
microphone.sensitivityDb = (index % 3) * 2 - 2;
|
microphone.sensitivityDb = (index % 3) * 2 - 2;
|
||||||
microphone.phaseOffsetDeg = (index % 5) * 8 - 16;
|
microphone.phaseOffsetDeg = (index % 5) * 8 - 16;
|
||||||
|
microphone.frequencyResponse = [
|
||||||
|
{ frequencyHz: 0, gainDb: (index % 4) * 6 - 9, phaseDeg: (index % 8) * 30 - 105 },
|
||||||
|
{ frequencyHz: 4000, gainDb: (index % 4) * 6 - 9, phaseDeg: (index % 8) * 30 - 105 },
|
||||||
|
];
|
||||||
});
|
});
|
||||||
const scene = {
|
const scene = {
|
||||||
id: "das-scene",
|
id: "das-scene",
|
||||||
@@ -292,6 +493,21 @@ test("CPU DAS peak가 calibration 후 단일 tone의 실제 grid 위치를 찾
|
|||||||
assertNear(Math.max(...result.bands[0].rawMap), result.bands[0].peak.rawPower, 1e-4, "raw map peak");
|
assertNear(Math.max(...result.bands[0].rawMap), result.bands[0].peak.rawPower, 1e-4, "raw map peak");
|
||||||
assertNear(Math.max(...result.bands[0].displayMap), 1, 1e-6, "normalized display peak");
|
assertNear(Math.max(...result.bands[0].displayMap), 1, 1e-6, "normalized display peak");
|
||||||
assert(progressEvents.some(({ stage }) => stage === "beamforming"), "beamforming progress");
|
assert(progressEvents.some(({ stage }) => stage === "beamforming"), "beamforming progress");
|
||||||
|
|
||||||
|
const uncompensated = await computeDasMap({
|
||||||
|
channels: synthesis.channels,
|
||||||
|
sampleRate: synthesis.sampleRate,
|
||||||
|
arrayDefinition,
|
||||||
|
scene,
|
||||||
|
experiment: {
|
||||||
|
...experiment,
|
||||||
|
beamforming: { ...experiment.beamforming, compensateFrequencyResponse: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
result.bands[0].peak.rawPower > uncompensated.bands[0].peak.rawPower * 1.5,
|
||||||
|
"known response compensation improves coherent peak power",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("독립 복소 spectrum fixture에서 DAS steering 부호가 맞다", async () => {
|
test("독립 복소 spectrum fixture에서 DAS steering 부호가 맞다", async () => {
|
||||||
@@ -329,6 +545,21 @@ test("독립 복소 spectrum fixture에서 DAS steering 부호가 맞다", async
|
|||||||
});
|
});
|
||||||
assertNear(map[1], 1, 1e-6, "target coherent power");
|
assertNear(map[1], 1, 1e-6, "target coherent power");
|
||||||
assert(map[1] > map[0] * 1.1, `wrong-sign alternative power=${map[0]}`);
|
assert(map[1] > map[0] * 1.1, `wrong-sign alternative power=${map[0]}`);
|
||||||
|
const legacyMap = await computeBinMap({
|
||||||
|
bin,
|
||||||
|
spectrum,
|
||||||
|
frameCount: 1,
|
||||||
|
arrayDefinition,
|
||||||
|
pointCount: 2,
|
||||||
|
distances: createDistanceMatrix(arrayDefinition, scan),
|
||||||
|
sampleRate,
|
||||||
|
fftSize,
|
||||||
|
speedOfSound,
|
||||||
|
pointBatchSize: 64,
|
||||||
|
compensateFrequencyResponse: false,
|
||||||
|
});
|
||||||
|
assertEqual(legacyMap[0], map[0], "response-free map remains unchanged when compensation is disabled");
|
||||||
|
assertEqual(legacyMap[1], map[1], "response-free target power remains unchanged");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("CPU DAS가 event-loop 취소와 working-set 한도를 지킨다", async () => {
|
test("CPU DAS가 event-loop 취소와 working-set 한도를 지킨다", async () => {
|
||||||
|
|||||||
@@ -61,6 +61,43 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
|
|||||||
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
|
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shoebox 음향 환경의 room과 occluder 경계를 검증한다", async () => {
|
||||||
|
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
|
||||||
|
scene.acoustics = {
|
||||||
|
model: "shoebox",
|
||||||
|
room: { min: [-2, -2, -1], max: [2, 2, 3] },
|
||||||
|
reflections: { order: 1, wallGainDb: -8, scattering: 0.2 },
|
||||||
|
diffuseReverb: { enabled: true, rt60Seconds: 0.35, gainDb: -20, tapCount: 12 },
|
||||||
|
occluders: [{
|
||||||
|
id: "screen",
|
||||||
|
min: [-0.2, -0.2, 0.8],
|
||||||
|
max: [0.2, 0.2, 1],
|
||||||
|
transmissionDb: -18,
|
||||||
|
diffractionGainDb: -12,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
assertEqual((await validateScene(scene)).length, 0, "valid shoebox scene");
|
||||||
|
|
||||||
|
scene.acoustics.room.max[0] = scene.acoustics.room.min[0];
|
||||||
|
let issues = await validateScene(scene);
|
||||||
|
assert(issues.some(({ path }) => path === "$.acoustics.room"), "unordered room bounds");
|
||||||
|
|
||||||
|
scene.acoustics.room.max[0] = 2;
|
||||||
|
scene.acoustics.occluders[0].max[2] = 4;
|
||||||
|
issues = await validateScene(scene);
|
||||||
|
assert(
|
||||||
|
issues.some(({ path, message }) => path.includes("occluders[0]") && message.includes("room")),
|
||||||
|
"occluder outside room",
|
||||||
|
);
|
||||||
|
|
||||||
|
scene.acoustics = {
|
||||||
|
model: "free-field",
|
||||||
|
room: { min: [-2, -2, -1], max: [2, 2, 3] },
|
||||||
|
};
|
||||||
|
issues = await validateScene(scene);
|
||||||
|
assert(issues.some(({ path }) => path === "$.acoustics"), "unused free-field settings");
|
||||||
|
});
|
||||||
|
|
||||||
test("Experiment 피크 검출 임계값 비율은 재현 가능하고 유효한 범위여야 한다", async () => {
|
test("Experiment 피크 검출 임계값 비율은 재현 가능하고 유효한 범위여야 한다", async () => {
|
||||||
const [arrayDefinition, scene, experiment] = await Promise.all([
|
const [arrayDefinition, scene, experiment] = await Promise.all([
|
||||||
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
|
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
|
||||||
@@ -125,6 +162,32 @@ test("중복 마이크 id를 거부한다", async () => {
|
|||||||
assert((await validateArrayDefinition(arrayDefinition)).some(({ message }) => message.includes("중복")));
|
assert((await validateArrayDefinition(arrayDefinition)).some(({ message }) => message.includes("중복")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("마이크 주파수 응답점은 안전한 범위에서 주파수 오름차순이어야 한다", async () => {
|
||||||
|
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
|
||||||
|
arrayDefinition.microphones[0].frequencyResponse = [
|
||||||
|
{ frequencyHz: 20, gainDb: -3 },
|
||||||
|
{ frequencyHz: 1000, gainDb: 1.5, phaseDeg: 12 },
|
||||||
|
{ frequencyHz: 20_000, gainDb: -2 },
|
||||||
|
];
|
||||||
|
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "valid response curve");
|
||||||
|
|
||||||
|
arrayDefinition.microphones[0].frequencyResponse[2].frequencyHz = 1000;
|
||||||
|
let issues = await validateArrayDefinition(arrayDefinition);
|
||||||
|
assert(
|
||||||
|
issues.some(({ path, message }) =>
|
||||||
|
path === "$.microphones[0].frequencyResponse[2].frequencyHz" &&
|
||||||
|
message.includes("엄격히 증가")),
|
||||||
|
"duplicate frequency is rejected with sorting guidance",
|
||||||
|
);
|
||||||
|
|
||||||
|
arrayDefinition.microphones[0].frequencyResponse[2].frequencyHz = 1_000_001;
|
||||||
|
issues = await validateArrayDefinition(arrayDefinition);
|
||||||
|
assert(
|
||||||
|
issues.some(({ path }) => path === "$.microphones[0].frequencyResponse[2].frequencyHz"),
|
||||||
|
"globally bounded frequency is enforced without a Scene Nyquist dependency",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("Nyquist를 넘는 tone을 거부한다", async () => {
|
test("Nyquist를 넘는 tone을 거부한다", async () => {
|
||||||
const scene = {
|
const scene = {
|
||||||
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
|
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
|
||||||
@@ -156,11 +219,13 @@ test("JSON Schema의 누락 필드와 범위·알 수 없는 필드를 거부한
|
|||||||
scene.unexpected = true;
|
scene.unexpected = true;
|
||||||
delete experiment.beamforming.normalization;
|
delete experiment.beamforming.normalization;
|
||||||
experiment.beamforming.dynamicRangeDb = 4;
|
experiment.beamforming.dynamicRangeDb = 4;
|
||||||
|
experiment.beamforming.compensateFrequencyResponse = "false";
|
||||||
assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("phaseOffsetDeg")));
|
assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("phaseOffsetDeg")));
|
||||||
assert((await validateScene(scene)).some(({ path }) => path === "$.unexpected"));
|
assert((await validateScene(scene)).some(({ path }) => path === "$.unexpected"));
|
||||||
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
|
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
|
||||||
assert(experimentIssues.some(({ path }) => path.endsWith("normalization")));
|
assert(experimentIssues.some(({ path }) => path.endsWith("normalization")));
|
||||||
assert(experimentIssues.some(({ path }) => path.endsWith("dynamicRangeDb")));
|
assert(experimentIssues.some(({ path }) => path.endsWith("dynamicRangeDb")));
|
||||||
|
assert(experimentIssues.some(({ path }) => path.endsWith("compensateFrequencyResponse")));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => {
|
test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { computeDasMap } from "/packages/acoustics-core/das.js";
|
||||||
|
import {
|
||||||
|
createLocalizationPlan,
|
||||||
|
createStreamingLocalizer,
|
||||||
|
localizeMultichannel,
|
||||||
|
} from "/packages/acoustics-core/realtime-localizer.js";
|
||||||
|
import { distance3 } from "/packages/acoustics-core/math.js";
|
||||||
|
|
||||||
|
function createFixture(sampleCount = 320) {
|
||||||
|
const sampleRate = 4096;
|
||||||
|
const speedOfSound = 343;
|
||||||
|
const frequency = 512;
|
||||||
|
const target = [0.1, -0.1, 1];
|
||||||
|
const array = {
|
||||||
|
id: "portable-array",
|
||||||
|
microphones: [
|
||||||
|
{
|
||||||
|
id: "nw",
|
||||||
|
position: [-0.12, 0.12, 0],
|
||||||
|
sensitivityDb: 1.5,
|
||||||
|
phaseOffsetDeg: -4,
|
||||||
|
frequencyResponse: [
|
||||||
|
{ frequencyHz: 400, gainDb: -1, phaseDeg: -3 },
|
||||||
|
{ frequencyHz: 600, gainDb: 2, phaseDeg: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: "ne", position: [0.12, 0.12, 0], sensitivityDb: -0.5, phaseOffsetDeg: 2 },
|
||||||
|
{ id: "sw", position: [-0.12, -0.12, 0] },
|
||||||
|
{ id: "se", position: [0.12, -0.12, 0] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const channels = array.microphones.map((microphone) => {
|
||||||
|
const delay = distance3(target, microphone.position) / speedOfSound;
|
||||||
|
return Float32Array.from(
|
||||||
|
{ length: sampleCount },
|
||||||
|
(_, sampleIndex) =>
|
||||||
|
Math.sin(2 * Math.PI * frequency * (sampleIndex / sampleRate - delay)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const scan = {
|
||||||
|
z: 1,
|
||||||
|
xMin: -0.2,
|
||||||
|
xMax: 0.2,
|
||||||
|
yMin: -0.1,
|
||||||
|
yMax: 0.1,
|
||||||
|
columns: 5,
|
||||||
|
rows: 3,
|
||||||
|
};
|
||||||
|
const bands = [{ id: "tone", minHz: 500, maxHz: 524, color: "#65f2bf" }];
|
||||||
|
const config = {
|
||||||
|
sampleRate,
|
||||||
|
array,
|
||||||
|
scan,
|
||||||
|
bands,
|
||||||
|
speedOfSound,
|
||||||
|
stft: { fftSize: 64, hopSize: 32 },
|
||||||
|
normalization: "peak",
|
||||||
|
maximumSteeringCacheBytes: 1024 * 1024,
|
||||||
|
computeOptions: {
|
||||||
|
pointBatchSize: 1024,
|
||||||
|
stftTransformBatchSize: 1024,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { sampleRate, speedOfSound, target, array, channels, scan, bands, config };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFloat32Exact(actual, expected, label) {
|
||||||
|
assertEqual(actual.length, expected.length, `${label} length`);
|
||||||
|
for (let index = 0; index < actual.length; index += 1) {
|
||||||
|
assertEqual(actual[index], expected[index], `${label}[${index}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sliceChannels(channels, start, end) {
|
||||||
|
return channels.map((channel) => channel.slice(start, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("portable localizer가 Scene 합성 없이 기존 CPU DAS와 bit-exact map을 만든다", async () => {
|
||||||
|
const fixture = createFixture(128);
|
||||||
|
const experiment = {
|
||||||
|
stft: { fftSize: 64, hopSize: 32, window: "hann" },
|
||||||
|
scan: fixture.scan,
|
||||||
|
frequencyBands: fixture.bands,
|
||||||
|
beamforming: { normalization: "peak", compensateFrequencyResponse: true },
|
||||||
|
};
|
||||||
|
const baseline = await computeDasMap({
|
||||||
|
channels: fixture.channels,
|
||||||
|
sampleRate: fixture.sampleRate,
|
||||||
|
arrayDefinition: fixture.array,
|
||||||
|
scene: { speedOfSound: fixture.speedOfSound },
|
||||||
|
experiment,
|
||||||
|
pointBatchSize: 1024,
|
||||||
|
stftTransformBatchSize: 1024,
|
||||||
|
});
|
||||||
|
const plan = createLocalizationPlan(fixture.config);
|
||||||
|
const portable = await localizeMultichannel({
|
||||||
|
plan,
|
||||||
|
channels: fixture.channels,
|
||||||
|
});
|
||||||
|
|
||||||
|
assertFloat32Exact(portable.bands[0].rawMap, baseline.bands[0].rawMap, "raw map");
|
||||||
|
assertFloat32Exact(portable.bands[0].displayMap, baseline.bands[0].displayMap, "display map");
|
||||||
|
assertEqual(portable.bands[0].peak.index, baseline.bands[0].peak.index, "peak index");
|
||||||
|
assertEqual(portable.locations[0].bandId, "tone", "portable location band");
|
||||||
|
assertEqual(portable.sampleCount, 128, "sample count");
|
||||||
|
assertEqual(portable.duration, 128 / fixture.sampleRate, "block duration");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CPU 분석 계획이 반복 geometry·steering 연산과 bin map 할당을 실제로 제거한다", async () => {
|
||||||
|
const fixture = createFixture(128);
|
||||||
|
const plan = createLocalizationPlan(fixture.config);
|
||||||
|
const first = await localizeMultichannel({ plan, channels: fixture.channels });
|
||||||
|
const second = await localizeMultichannel({ plan, channels: fixture.channels });
|
||||||
|
const cache = second.analysisCache;
|
||||||
|
const pointMicrophonePairs = fixture.scan.columns * fixture.scan.rows * fixture.array.microphones.length;
|
||||||
|
|
||||||
|
assertEqual(cache.analysisAttempts, 2, "analysis attempts");
|
||||||
|
assertEqual(cache.completedAnalyses, 2, "completed analyses");
|
||||||
|
assertEqual(cache.distanceMatrixBuilds, 1, "one retained distance matrix");
|
||||||
|
assertEqual(cache.distanceEvaluations, pointMicrophonePairs, "distance evaluations performed");
|
||||||
|
assertEqual(
|
||||||
|
cache.avoidedDistanceEvaluations,
|
||||||
|
pointMicrophonePairs,
|
||||||
|
"second distance pass avoided",
|
||||||
|
);
|
||||||
|
assert(cache.steeringCacheEnabled, "bounded steering cache enabled");
|
||||||
|
assertEqual(cache.steeringMatrixBuilds, 1, "one steering matrix build");
|
||||||
|
assertEqual(cache.steeringCacheHits, 1, "second steering pass is a cache hit");
|
||||||
|
assertEqual(
|
||||||
|
cache.avoidedSteeringPointMicrophonePairs,
|
||||||
|
pointMicrophonePairs,
|
||||||
|
"second trigonometric steering pass avoided",
|
||||||
|
);
|
||||||
|
assertEqual(cache.avoidedBinMapAllocations, 1, "second temporary bin map allocation avoided");
|
||||||
|
assert(cache.retainedBytes > 0, "retained cache bytes are reported");
|
||||||
|
assertFloat32Exact(second.bands[0].rawMap, first.bands[0].rawMap, "cached repeat raw map");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streaming localizer 결과가 임의 chunk 경계와 무관하다", async () => {
|
||||||
|
const fixture = createFixture(320);
|
||||||
|
const streamConfig = {
|
||||||
|
...fixture.config,
|
||||||
|
analysisWindowSamples: 128,
|
||||||
|
analysisHopSamples: 64,
|
||||||
|
maximumBufferedSamples: 512,
|
||||||
|
maximumResultsPerCall: 4,
|
||||||
|
};
|
||||||
|
const whole = createStreamingLocalizer(streamConfig);
|
||||||
|
whole.append(fixture.channels);
|
||||||
|
const wholeResults = await whole.analyzeAvailable({ maximumResults: 4 });
|
||||||
|
|
||||||
|
const chunked = createStreamingLocalizer(streamConfig);
|
||||||
|
const chunkSizes = [17, 83, 1, 91, 128];
|
||||||
|
const chunkedResults = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunkSize of chunkSizes) {
|
||||||
|
const end = offset + chunkSize;
|
||||||
|
chunkedResults.push(...await chunked.push(sliceChannels(fixture.channels, offset, end)));
|
||||||
|
offset = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(wholeResults.length, 4, "whole block window count");
|
||||||
|
assertEqual(chunkedResults.length, wholeResults.length, "chunked window count");
|
||||||
|
wholeResults.forEach((expected, index) => {
|
||||||
|
const actual = chunkedResults[index];
|
||||||
|
assertEqual(actual.startSample, index * 64, `chunked start sample ${index}`);
|
||||||
|
assertEqual(actual.endSample, index * 64 + 128, `chunked end sample ${index}`);
|
||||||
|
assertEqual(actual.sequence, index, `chunked sequence ${index}`);
|
||||||
|
assertFloat32Exact(actual.bands[0].rawMap, expected.bands[0].rawMap, `chunked map ${index}`);
|
||||||
|
});
|
||||||
|
assertEqual(chunked.getState().droppedSamples, 0, "no samples dropped under capacity");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streaming ring buffer와 호출별 결과 수가 설정된 상한을 지킨다", async () => {
|
||||||
|
const fixture = createFixture(400);
|
||||||
|
let planLimitError;
|
||||||
|
try {
|
||||||
|
createLocalizationPlan({ ...fixture.config, maximumPlanBytes: 64 });
|
||||||
|
} catch (error) {
|
||||||
|
planLimitError = error;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
planLimitError instanceof RangeError &&
|
||||||
|
planLimitError.message.includes("maximumPlanBytes"),
|
||||||
|
"analysis plan allocation hard limit",
|
||||||
|
);
|
||||||
|
const invalidResponseConfig = structuredClone(fixture.config);
|
||||||
|
invalidResponseConfig.array.microphones[0].frequencyResponse = [
|
||||||
|
{ frequencyHz: 1000, gainDb: 0 },
|
||||||
|
{ frequencyHz: 1000, gainDb: -3 },
|
||||||
|
];
|
||||||
|
let responseError;
|
||||||
|
try {
|
||||||
|
createLocalizationPlan(invalidResponseConfig);
|
||||||
|
} catch (error) {
|
||||||
|
responseError = error;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
responseError instanceof RangeError && responseError.message.includes("strictly increasing"),
|
||||||
|
"portable microphone response validation",
|
||||||
|
);
|
||||||
|
let inputLimitError;
|
||||||
|
try {
|
||||||
|
createStreamingLocalizer({
|
||||||
|
...fixture.config,
|
||||||
|
analysisWindowSamples: 128,
|
||||||
|
maximumBufferedSamples: 128,
|
||||||
|
maximumInputBufferBytes: 1024,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
inputLimitError = error;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
inputLimitError instanceof RangeError &&
|
||||||
|
inputLimitError.message.includes("maximumInputBufferBytes"),
|
||||||
|
"input allocation hard limit",
|
||||||
|
);
|
||||||
|
const bounded = createStreamingLocalizer({
|
||||||
|
...fixture.config,
|
||||||
|
analysisWindowSamples: 128,
|
||||||
|
analysisHopSamples: 64,
|
||||||
|
maximumBufferedSamples: 128,
|
||||||
|
maximumResultsPerCall: 1,
|
||||||
|
});
|
||||||
|
const appended = bounded.append(fixture.channels);
|
||||||
|
assertEqual(appended.bufferedSamples, 128, "ring capacity");
|
||||||
|
assertEqual(appended.droppedSamples, 272, "oldest samples dropped");
|
||||||
|
assertEqual(appended.bufferStartSample, 272, "retained absolute sample origin");
|
||||||
|
assertEqual(
|
||||||
|
appended.retainedInputBytes,
|
||||||
|
fixture.array.microphones.length * (128 + 128) * Float32Array.BYTES_PER_ELEMENT,
|
||||||
|
"fixed ring and analysis workspace bytes",
|
||||||
|
);
|
||||||
|
const boundedResults = await bounded.analyzeAvailable();
|
||||||
|
assertEqual(boundedResults.length, 1, "one bounded result");
|
||||||
|
assertEqual(boundedResults[0].startSample, 272, "latest retained window analyzed");
|
||||||
|
|
||||||
|
const resultBound = createStreamingLocalizer({
|
||||||
|
...fixture.config,
|
||||||
|
analysisWindowSamples: 128,
|
||||||
|
analysisHopSamples: 64,
|
||||||
|
maximumBufferedSamples: 512,
|
||||||
|
maximumResultsPerCall: 2,
|
||||||
|
});
|
||||||
|
resultBound.append(fixture.channels);
|
||||||
|
const first = await resultBound.analyzeAvailable();
|
||||||
|
const second = await resultBound.analyzeAvailable();
|
||||||
|
assertEqual(first.length, 2, "first result batch bound");
|
||||||
|
assertEqual(second.length, 2, "second result batch bound");
|
||||||
|
assertEqual(resultBound.getState().availableAnalysisCount, 1, "remaining work stays buffered");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streaming 분석 취소가 AbortError를 전달하고 계획 lock을 해제한다", async () => {
|
||||||
|
const fixture = createFixture(128);
|
||||||
|
const stream = createStreamingLocalizer({
|
||||||
|
...fixture.config,
|
||||||
|
analysisWindowSamples: 128,
|
||||||
|
analysisHopSamples: 64,
|
||||||
|
maximumBufferedSamples: 256,
|
||||||
|
maximumResultsPerCall: 1,
|
||||||
|
});
|
||||||
|
stream.append(fixture.channels);
|
||||||
|
const controller = new AbortController();
|
||||||
|
let cancellationRequested = false;
|
||||||
|
let caught;
|
||||||
|
try {
|
||||||
|
await stream.analyzeAvailable({
|
||||||
|
signal: controller.signal,
|
||||||
|
computeOptions: { stftTransformBatchSize: 1 },
|
||||||
|
onProgress: ({ stage, completedTransforms }) => {
|
||||||
|
if (stage === "stft" && completedTransforms > 0 && !cancellationRequested) {
|
||||||
|
cancellationRequested = true;
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
assert(cancellationRequested, "cancellation requested during STFT");
|
||||||
|
assertEqual(caught?.name, "AbortError", "AbortError propagated");
|
||||||
|
assertEqual(stream.getState().busy, false, "stream lock released");
|
||||||
|
assertEqual(stream.plan.dasPlan.busy, false, "DAS plan lock released");
|
||||||
|
assertEqual(stream.getState().failedAnalyses, 1, "failed analysis counted");
|
||||||
|
});
|
||||||
@@ -81,6 +81,36 @@ test("STFT batch를 bin-frame-microphone-complex 순서로 packing한다", () =>
|
|||||||
expected.forEach((value, index) => assertEqual(packed[index], value, `packed STFT value ${index}`));
|
expected.forEach((value, index) => assertEqual(packed[index], value, `packed STFT value ${index}`));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("WebGPU STFT packing이 microphone 주파수 응답을 bin별로 역보정한다", () => {
|
||||||
|
const stft = {
|
||||||
|
frameCount: 1,
|
||||||
|
microphoneCount: 1,
|
||||||
|
spectra: new Map([[8, { real: Float32Array.of(0), imaginary: Float32Array.of(2) }]]),
|
||||||
|
};
|
||||||
|
const microphones = [{
|
||||||
|
frequencyResponse: [
|
||||||
|
{ frequencyHz: 0, gainDb: 6.020599913, phaseDeg: 90 },
|
||||||
|
{ frequencyHz: 4000, gainDb: 6.020599913, phaseDeg: 90 },
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
const compensated = packStftBatch(stft, [8], {
|
||||||
|
microphones,
|
||||||
|
sampleRate: 8000,
|
||||||
|
fftSize: 256,
|
||||||
|
});
|
||||||
|
assertNear(compensated[0], 1, 1e-6, "inverse response real");
|
||||||
|
assertNear(compensated[1], 0, 1e-6, "inverse response imaginary");
|
||||||
|
|
||||||
|
const uncompensated = packStftBatch(stft, [8], {
|
||||||
|
microphones,
|
||||||
|
sampleRate: 8000,
|
||||||
|
fftSize: 256,
|
||||||
|
compensateFrequencyResponse: false,
|
||||||
|
});
|
||||||
|
assertEqual(uncompensated[0], 0, "disabled compensation real");
|
||||||
|
assertEqual(uncompensated[1], 2, "disabled compensation imaginary");
|
||||||
|
});
|
||||||
|
|
||||||
test("WebGPU batch size가 storage, dispatch, memory 한도를 모두 지킨다", () => {
|
test("WebGPU batch size가 storage, dispatch, memory 한도를 모두 지킨다", () => {
|
||||||
const workload = {
|
const workload = {
|
||||||
selectedBins: [4, 5, 6, 7, 8],
|
selectedBins: [4, 5, 6, 7, 8],
|
||||||
|
|||||||
Reference in New Issue
Block a user