feat: add realistic portable localization core

This commit is contained in:
Codex
2026-08-28 18:19:31 +09:00
parent bc63ada1cd
commit d002a2bd70
20 changed files with 2345 additions and 70 deletions
+82 -5
View File
@@ -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
View File
@@ -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;
}
}
+244
View File
@@ -0,0 +1,244 @@
import { dbToLinear, distance3 } from "./math.js";
import { hashString, whiteNoiseAt } from "./noise.js";
const WALLS = [
{ id: "xMin", axis: 0, boundary: "min" },
{ id: "xMax", axis: 0, boundary: "max" },
{ id: "yMin", axis: 1, boundary: "min" },
{ id: "yMax", axis: 1, boundary: "max" },
{ id: "zMin", axis: 2, boundary: "min" },
{ id: "zMax", axis: 2, boundary: "max" },
];
const GEOMETRY_EPSILON = 1e-9;
function insideBounds(position, bounds, epsilon = GEOMETRY_EPSILON) {
return position.every(
(coordinate, axis) =>
coordinate >= bounds.min[axis] - epsilon && coordinate <= bounds.max[axis] + epsilon,
);
}
function segmentIntersectsBox(start, end, box) {
let entry = 0;
let exit = 1;
for (let axis = 0; axis < 3; axis += 1) {
const delta = end[axis] - start[axis];
if (Math.abs(delta) <= GEOMETRY_EPSILON) {
if (start[axis] < box.min[axis] || start[axis] > box.max[axis]) return false;
continue;
}
const first = (box.min[axis] - start[axis]) / delta;
const second = (box.max[axis] - start[axis]) / delta;
entry = Math.max(entry, Math.min(first, second));
exit = Math.min(exit, Math.max(first, second));
if (entry > exit) return false;
}
return exit > GEOMETRY_EPSILON && entry < 1 - GEOMETRY_EPSILON;
}
function shortestCornerDetour(start, end, box) {
let shortestDistance = Infinity;
for (let mask = 0; mask < 8; mask += 1) {
const corner = [0, 1, 2].map((axis) =>
mask & (1 << axis) ? box.max[axis] : box.min[axis],
);
shortestDistance = Math.min(
shortestDistance,
distance3(start, corner) + distance3(corner, end),
);
}
return shortestDistance;
}
function imageSourceDistance(source, receiver, room, wall) {
if (!insideBounds(source, room) || !insideBounds(receiver, room)) return null;
const plane = room[wall.boundary][wall.axis];
const image = [...source];
image[wall.axis] = 2 * plane - source[wall.axis];
const denominator = receiver[wall.axis] - image[wall.axis];
if (Math.abs(denominator) <= GEOMETRY_EPSILON) return null;
const amount = (plane - image[wall.axis]) / denominator;
if (amount <= GEOMETRY_EPSILON || amount >= 1 - GEOMETRY_EPSILON) return null;
const reflectionPoint = image.map(
(coordinate, axis) => coordinate + (receiver[axis] - coordinate) * amount,
);
for (let axis = 0; axis < 3; axis += 1) {
if (axis === wall.axis) continue;
if (
reflectionPoint[axis] < room.min[axis] - GEOMETRY_EPSILON ||
reflectionPoint[axis] > room.max[axis] + GEOMETRY_EPSILON
) {
return null;
}
}
return distance3(image, receiver);
}
function pathGain(distance, coefficient, referenceDistance, minimumDistance) {
return coefficient * referenceDistance / Math.max(distance, minimumDistance);
}
function deterministicUnit(seed, index) {
return (whiteNoiseAt(seed, index) + 1) / 2;
}
function stableSeed(scene, sourceId, receiverId) {
return (
(scene.seed ?? 0) ^
hashString(String(sourceId ?? "source")) ^
Math.imul(hashString(String(receiverId ?? "receiver")), 0x45d9f3b)
) >>> 0;
}
/**
* Build a sparse, deterministic set of propagation paths for one source/receiver pair.
*
* The optional shoebox model is intentionally bounded: direct sound, six first-order
* image sources, at most one edge-detour per occluder, and a capped diffuse tail.
* It is useful for array/algorithm comparisons; it is not a room-acoustics solver.
*/
export function buildAcousticPaths(options) {
const {
scene,
sourcePosition,
receiverPosition,
sourceId,
receiverId,
referenceDistance = 1,
minimumDistance = 0.01,
} = options;
const speedOfSound = scene.speedOfSound;
const directDistance = distance3(sourcePosition, receiverPosition);
const acoustics = scene.acoustics;
if (!acoustics || acoustics.model === "free-field") {
return [{
kind: "direct",
distance: directDistance,
delaySeconds: directDistance / speedOfSound,
gain: pathGain(directDistance, 1, referenceDistance, minimumDistance),
}];
}
const paths = [];
const blockers = (acoustics.occluders ?? []).filter((occluder) =>
segmentIntersectsBox(sourcePosition, receiverPosition, occluder),
);
const transmission = blockers.reduce(
(coefficient, occluder) => coefficient * dbToLinear(occluder.transmissionDb),
1,
);
paths.push({
kind: "direct",
distance: directDistance,
delaySeconds: directDistance / speedOfSound,
gain: pathGain(directDistance, transmission, referenceDistance, minimumDistance),
blockedBy: blockers.map(({ id }) => id),
});
for (const occluder of blockers) {
const distance = shortestCornerDetour(sourcePosition, receiverPosition, occluder);
paths.push({
kind: "diffraction",
occluderId: occluder.id,
distance,
delaySeconds: distance / speedOfSound,
gain: pathGain(
distance,
dbToLinear(occluder.diffractionGainDb),
referenceDistance,
minimumDistance,
),
});
}
const reflections = acoustics.reflections;
const seed = stableSeed(scene, sourceId, receiverId);
if (reflections?.order === 1 && acoustics.room) {
const scattering = reflections.scattering ?? 0;
for (let wallIndex = 0; wallIndex < WALLS.length; wallIndex += 1) {
const wall = WALLS[wallIndex];
const distance = imageSourceDistance(
sourcePosition,
receiverPosition,
acoustics.room,
wall,
);
if (distance === null) continue;
const wallDb = reflections.perWallDb?.[wall.id] ?? reflections.wallGainDb;
const reflectionCoefficient = dbToLinear(wallDb);
const specularCoefficient = reflectionCoefficient * Math.sqrt(1 - scattering);
if (specularCoefficient > 0) {
paths.push({
kind: "reflection",
surface: wall.id,
distance,
delaySeconds: distance / speedOfSound,
gain: pathGain(distance, specularCoefficient, referenceDistance, minimumDistance),
});
}
if (scattering > 0) {
const jitterSeconds = 0.0002 + deterministicUnit(seed, wallIndex) * 0.0018;
const sign = whiteNoiseAt(seed ^ 0x9e3779b9, wallIndex) < 0 ? -1 : 1;
paths.push({
kind: "scattering",
surface: wall.id,
distance: distance + jitterSeconds * speedOfSound,
delaySeconds: distance / speedOfSound + jitterSeconds,
gain: pathGain(
distance,
reflectionCoefficient * Math.sqrt(scattering) * sign,
referenceDistance,
minimumDistance,
),
});
}
}
}
const diffuse = acoustics.diffuseReverb;
if (diffuse?.enabled) {
const tapGain = dbToLinear(diffuse.gainDb) / Math.sqrt(diffuse.tapCount);
for (let tapIndex = 0; tapIndex < diffuse.tapCount; tapIndex += 1) {
const progress = (tapIndex + 1) / (diffuse.tapCount + 1);
const tailTime = progress * diffuse.rt60Seconds;
const jitterLimit = Math.min(0.001, diffuse.rt60Seconds / (diffuse.tapCount * 4));
const jitter = deterministicUnit(seed ^ 0xa511e9b3, tapIndex) * jitterLimit;
const decay = 10 ** ((-60 * tailTime / diffuse.rt60Seconds) / 20);
const sign = whiteNoiseAt(seed ^ 0x63d83595, tapIndex) < 0 ? -1 : 1;
paths.push({
kind: "diffuse",
distance: directDistance + (tailTime + jitter) * speedOfSound,
delaySeconds: directDistance / speedOfSound + tailTime + jitter,
gain: pathGain(
directDistance,
tapGain * decay * sign,
referenceDistance,
minimumDistance,
),
});
}
}
return paths;
}
export function maximumAcousticDelaySeconds(options) {
const { scene, arrayDefinition, referenceDistance, minimumDistance } = options;
let maximum = 0;
for (const source of scene.sources) {
for (const microphone of arrayDefinition.microphones) {
const paths = buildAcousticPaths({
scene,
sourcePosition: source.position,
receiverPosition: microphone.position,
sourceId: source.id,
receiverId: microphone.id,
referenceDistance,
minimumDistance,
});
for (const path of paths) maximum = Math.max(maximum, path.delaySeconds);
}
}
return maximum;
}
+89 -9
View File
@@ -1,4 +1,5 @@
import { fftInPlace } from "./fft.js"; import { 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);
}
+3
View File
@@ -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";
+50 -18
View File
@@ -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);
}
+111 -2
View File
@@ -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;
+57 -4
View File
@@ -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) {
packed[binOffset + index * 2] = spectrum.real[index]; if (!inverseResponses) {
packed[binOffset + index * 2 + 1] = spectrum.imaginary[index]; packed[binOffset + index * 2] = spectrum.real[index];
packed[binOffset + index * 2 + 1] = spectrum.imaginary[index];
continue;
}
const microphoneIndex = index % stft.microphoneCount;
const responseOffset = (binIndex * stft.microphoneCount + microphoneIndex) * 2;
const inverseReal = inverseResponses[responseOffset];
const inverseImaginary = inverseResponses[responseOffset + 1];
const inputReal = spectrum.real[index];
const inputImaginary = spectrum.imaginary[index];
packed[binOffset + index * 2] =
inputReal * inverseReal - inputImaginary * inverseImaginary;
packed[binOffset + index * 2 + 1] =
inputReal * inverseImaginary + inputImaginary * inverseReal;
} }
}); });
return packed; 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
View File
@@ -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 근사의 입력입니다.
+14 -1
View File
@@ -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 }
} }
} }
} }
+2 -1
View File
@@ -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": {
+62
View File
@@ -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"],
+1
View File
@@ -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");
+2
View File
@@ -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
+231
View File
@@ -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 () => {
+65
View File
@@ -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 () => {
+285
View File
@@ -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");
});
+30
View File
@@ -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],