feat: implement free-field CPU beamforming
This commit is contained in:
@@ -15,6 +15,10 @@ scene JSON에는 192채널 원시 sample을 넣지 않는다. 합성 tone/noise
|
|||||||
재생성하고, 녹음 파일과 이미지는 manifest 기준의 안전한 상대 경로로 참조한다. 사용자 컴퓨터의
|
재생성하고, 녹음 파일과 이미지는 manifest 기준의 안전한 상대 경로로 참조한다. 사용자 컴퓨터의
|
||||||
절대 경로, URL, `..` 경로는 저장하지 않는다.
|
절대 경로, URL, `..` 경로는 저장하지 않는다.
|
||||||
|
|
||||||
|
array의 `sensitivityDb`와 `phaseOffsetDeg`는 각 마이크 수신 channel에 적용되는 편차다. 합성기는
|
||||||
|
이를 신호에 주입하고, beamformer는 알려진 calibration 값의 역 gain/phase를 적용한다. 따라서
|
||||||
|
두 값이 0이 아닌 배열도 오류 없이 조용히 무시되지 않는다.
|
||||||
|
|
||||||
## 오디오와 map 정규화
|
## 오디오와 map 정규화
|
||||||
|
|
||||||
`scene.mix.normalization`은 마이크 신호 합성 전에 적용할 오디오 mix 정책이다. `none`은 원래
|
`scene.mix.normalization`은 마이크 신호 합성 전에 적용할 오디오 mix 정책이다. `none`은 원래
|
||||||
|
|||||||
@@ -1,3 +1,24 @@
|
|||||||
# Acoustics Core
|
# Acoustics Core
|
||||||
|
|
||||||
두 앱이 공유하는 좌표계, 자유음장 전파, STFT, CPU 기준 DAS 및 평가 로직을 둡니다.
|
두 앱이 공유하는 무의존성 수치 기준 구현입니다.
|
||||||
|
|
||||||
|
- 결정적 tone/white/pink noise 및 오디오 asset resampling
|
||||||
|
- 자유음장 점 음원의 `delay = r / c`, `amplitude = 1 / r` 구면 전파
|
||||||
|
- zero-padded FFT phase shift 기반 fractional sample delay
|
||||||
|
- radix-2 FFT, Hann STFT와 선택 bin 추출
|
||||||
|
- scan grid 거리 cache 및 conventional frequency-domain DAS
|
||||||
|
|
||||||
|
FFT는 `X[k] = Σ x[n] exp(-j2πkn/N)` 규약을 사용합니다. 마이크 신호의 전파 phase
|
||||||
|
`exp(-j2πfr/c)`를 정렬하기 위해 DAS steering은 `exp(+j2πfr/c)`를 곱합니다. 단일 tone의
|
||||||
|
ground-truth 위치에서 peak가 생기는 회귀 테스트로 이 부호를 고정합니다.
|
||||||
|
|
||||||
|
마이크 `sensitivityDb`와 `phaseOffsetDeg`는 수신 channel에 편차로 적용하고, DAS에서는 알려진
|
||||||
|
calibration 값의 역 gain/phase로 보정합니다. raw power map과 표시용 normalized map은 서로 다른
|
||||||
|
buffer로 보존합니다. bin spectrum은 제한된 batch로 처리하며 point batch 사이에서 event loop를
|
||||||
|
양보해 진행 callback과 `AbortSignal` 취소가 동작합니다.
|
||||||
|
|
||||||
|
현재 전파 모델은 자유음장·무지향성 점 음원이며 잔향은 포함하지 않습니다. 전체 signal을
|
||||||
|
메모리에 보관하는 CPU 기준 구현이므로 source/channel과 delay FFT working set을 512 MiB로 제한합니다.
|
||||||
|
장시간 입력은 이후 chunk/STFT streaming backend에서 지원합니다.
|
||||||
|
|
||||||
|
192채널·50×50 fixture는 브라우저 통합 테스트에서 실제 합성부터 DAS peak 검출까지 실행합니다.
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { dbToLinear, createScanPoints, distance3, findPeak, scanIndexToPosition, TAU } from "./math.js";
|
||||||
|
import { binsForBand, extractStftBins, getFrameCount } from "./stft.js";
|
||||||
|
|
||||||
|
function throwIfAborted(signal) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
const error = new Error("계산이 취소되었습니다.");
|
||||||
|
error.name = "AbortError";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function yieldToEventLoop() {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDistanceMatrix(arrayDefinition, scan) {
|
||||||
|
const points = createScanPoints(scan);
|
||||||
|
const microphoneCount = arrayDefinition.microphones.length;
|
||||||
|
const pointCount = scan.columns * scan.rows;
|
||||||
|
const distances = new Float32Array(pointCount * microphoneCount);
|
||||||
|
for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
|
||||||
|
const pointOffset = pointIndex * 4;
|
||||||
|
const point = [points[pointOffset], points[pointOffset + 1], points[pointOffset + 2]];
|
||||||
|
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
|
||||||
|
distances[pointIndex * microphoneCount + microphoneIndex] = distance3(
|
||||||
|
point,
|
||||||
|
arrayDefinition.microphones[microphoneIndex].position,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return distances;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeBinMap(options) {
|
||||||
|
const {
|
||||||
|
bin,
|
||||||
|
spectrum,
|
||||||
|
frameCount,
|
||||||
|
arrayDefinition,
|
||||||
|
pointCount,
|
||||||
|
distances,
|
||||||
|
sampleRate,
|
||||||
|
fftSize,
|
||||||
|
speedOfSound,
|
||||||
|
signal,
|
||||||
|
pointBatchSize,
|
||||||
|
} = options;
|
||||||
|
const microphoneCount = arrayDefinition.microphones.length;
|
||||||
|
const output = new Float32Array(pointCount);
|
||||||
|
const steeringReal = new Float64Array(microphoneCount);
|
||||||
|
const steeringImaginary = new Float64Array(microphoneCount);
|
||||||
|
const angularWaveNumber = (TAU * bin * sampleRate) / (fftSize * speedOfSound);
|
||||||
|
const microphoneScale = 1 / microphoneCount;
|
||||||
|
|
||||||
|
for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
const distanceOffset = pointIndex * microphoneCount;
|
||||||
|
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
|
||||||
|
const microphone = arrayDefinition.microphones[microphoneIndex];
|
||||||
|
const calibrationPhase = ((microphone.phaseOffsetDeg ?? 0) * Math.PI) / 180;
|
||||||
|
const phase = angularWaveNumber * distances[distanceOffset + microphoneIndex] - calibrationPhase;
|
||||||
|
const calibrationGain = dbToLinear(-(microphone.sensitivityDb ?? 0));
|
||||||
|
steeringReal[microphoneIndex] = Math.cos(phase) * calibrationGain;
|
||||||
|
steeringImaginary[microphoneIndex] = Math.sin(phase) * calibrationGain;
|
||||||
|
}
|
||||||
|
|
||||||
|
let accumulatedPower = 0;
|
||||||
|
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
||||||
|
const spectrumOffset = frameIndex * microphoneCount;
|
||||||
|
let sumReal = 0;
|
||||||
|
let sumImaginary = 0;
|
||||||
|
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
|
||||||
|
const spectrumIndex = spectrumOffset + microphoneIndex;
|
||||||
|
const inputReal = spectrum.real[spectrumIndex];
|
||||||
|
const inputImaginary = spectrum.imaginary[spectrumIndex];
|
||||||
|
const phaseReal = steeringReal[microphoneIndex];
|
||||||
|
const phaseImaginary = steeringImaginary[microphoneIndex];
|
||||||
|
sumReal += inputReal * phaseReal - inputImaginary * phaseImaginary;
|
||||||
|
sumImaginary += inputReal * phaseImaginary + inputImaginary * phaseReal;
|
||||||
|
}
|
||||||
|
accumulatedPower += (sumReal * sumReal + sumImaginary * sumImaginary) * microphoneScale * microphoneScale;
|
||||||
|
}
|
||||||
|
output[pointIndex] = accumulatedPower / frameCount;
|
||||||
|
if ((pointIndex + 1) % pointBatchSize === 0) await yieldToEventLoop();
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateDasWorkload(options) {
|
||||||
|
const { channels, sampleRate, arrayDefinition, experiment } = options;
|
||||||
|
const { fftSize, hopSize } = experiment.stft;
|
||||||
|
const bandBins = experiment.frequencyBands.map((band) => binsForBand(band, sampleRate, fftSize));
|
||||||
|
const selectedBins = [...new Set(bandBins.flat())].sort((a, b) => a - b);
|
||||||
|
const frameCount = getFrameCount(channels[0].length, fftSize, hopSize);
|
||||||
|
const microphoneCount = arrayDefinition.microphones.length;
|
||||||
|
const pointCount = experiment.scan.columns * experiment.scan.rows;
|
||||||
|
const bandCount = experiment.frequencyBands.length;
|
||||||
|
const distanceBytes = pointCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT;
|
||||||
|
const outputBytes = bandCount * pointCount * Float32Array.BYTES_PER_ELEMENT * 2;
|
||||||
|
const binMapBytes = pointCount * Float32Array.BYTES_PER_ELEMENT;
|
||||||
|
const spectrumBytesPerBin = frameCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2;
|
||||||
|
const fftWorkspaceBytes = fftSize * Float64Array.BYTES_PER_ELEMENT * 2;
|
||||||
|
const baseWorkingBytes = distanceBytes + outputBytes + binMapBytes + fftWorkspaceBytes;
|
||||||
|
const complexMacCount = pointCount * microphoneCount * frameCount * selectedBins.length;
|
||||||
|
return {
|
||||||
|
bandBins,
|
||||||
|
selectedBins,
|
||||||
|
frameCount,
|
||||||
|
microphoneCount,
|
||||||
|
pointCount,
|
||||||
|
distanceBytes,
|
||||||
|
outputBytes,
|
||||||
|
spectrumBytesPerBin,
|
||||||
|
baseWorkingBytes,
|
||||||
|
complexMacCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeDasMap(options) {
|
||||||
|
const {
|
||||||
|
channels,
|
||||||
|
sampleRate,
|
||||||
|
arrayDefinition,
|
||||||
|
scene,
|
||||||
|
experiment,
|
||||||
|
signal,
|
||||||
|
onProgress,
|
||||||
|
maximumWorkingBytes = 512 * 1024 * 1024,
|
||||||
|
maximumComplexMacs = 1_000_000_000,
|
||||||
|
maximumFftButterflies = 1_000_000_000,
|
||||||
|
maximumSpectrumBatchBins = Number.MAX_SAFE_INTEGER,
|
||||||
|
pointBatchSize = 64,
|
||||||
|
stftTransformBatchSize = 32,
|
||||||
|
} = options;
|
||||||
|
if (channels.length !== arrayDefinition.microphones.length) {
|
||||||
|
throw new Error(`채널 수 ${channels.length}와 마이크 수 ${arrayDefinition.microphones.length}가 다릅니다.`);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(pointBatchSize) || pointBatchSize < 1) throw new Error("pointBatchSize는 1 이상의 정수여야 합니다.");
|
||||||
|
const workload = estimateDasWorkload({ channels, sampleRate, arrayDefinition, experiment });
|
||||||
|
if (workload.complexMacCount > maximumComplexMacs) {
|
||||||
|
throw new Error(
|
||||||
|
`CPU DAS 예상 복소 누산 ${workload.complexMacCount.toLocaleString()}회가 ` +
|
||||||
|
`현재 한도 ${maximumComplexMacs.toLocaleString()}회를 넘습니다. grid, duration 또는 대역폭을 줄이세요.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const availableSpectrumBytes = maximumWorkingBytes - workload.baseWorkingBytes;
|
||||||
|
const batchCapacity = Math.floor(availableSpectrumBytes / workload.spectrumBytesPerBin);
|
||||||
|
if (batchCapacity < 1) {
|
||||||
|
throw new Error(
|
||||||
|
`CPU DAS 기본 working set ${(workload.baseWorkingBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
||||||
|
`현재 한도 ${(maximumWorkingBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const batchSize = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(workload.selectedBins.length, batchCapacity, Math.max(1, maximumSpectrumBatchBins)),
|
||||||
|
);
|
||||||
|
const spectrumBatchCount = Math.ceil(workload.selectedBins.length / batchSize);
|
||||||
|
const fftButterflyCount =
|
||||||
|
spectrumBatchCount *
|
||||||
|
workload.frameCount *
|
||||||
|
workload.microphoneCount *
|
||||||
|
(experiment.stft.fftSize / 2) *
|
||||||
|
Math.log2(experiment.stft.fftSize);
|
||||||
|
if (fftButterflyCount > maximumFftButterflies) {
|
||||||
|
throw new Error(
|
||||||
|
`CPU STFT 예상 butterfly ${Math.ceil(fftButterflyCount).toLocaleString()}회가 ` +
|
||||||
|
`현재 한도 ${maximumFftButterflies.toLocaleString()}회를 넘습니다. duration, FFT 크기 또는 대역폭을 줄이세요.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const distances = createDistanceMatrix(arrayDefinition, experiment.scan);
|
||||||
|
const rawMaps = experiment.frequencyBands.map(() => new Float32Array(workload.pointCount));
|
||||||
|
const bandIndexesByBin = new Map();
|
||||||
|
workload.bandBins.forEach((bins, bandIndex) => {
|
||||||
|
bins.forEach((bin) => {
|
||||||
|
if (!bandIndexesByBin.has(bin)) bandIndexesByBin.set(bin, []);
|
||||||
|
bandIndexesByBin.get(bin).push(bandIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let completedBins = 0;
|
||||||
|
for (let batchStart = 0; batchStart < workload.selectedBins.length; batchStart += batchSize) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
await yieldToEventLoop();
|
||||||
|
throwIfAborted(signal);
|
||||||
|
const batchBins = workload.selectedBins.slice(batchStart, batchStart + batchSize);
|
||||||
|
const stft = await extractStftBins(channels, {
|
||||||
|
fftSize: experiment.stft.fftSize,
|
||||||
|
hopSize: experiment.stft.hopSize,
|
||||||
|
bins: batchBins,
|
||||||
|
signal,
|
||||||
|
transformBatchSize: stftTransformBatchSize,
|
||||||
|
onProgress: ({ completedTransforms, totalTransforms }) =>
|
||||||
|
onProgress?.({
|
||||||
|
stage: "stft",
|
||||||
|
completedBins,
|
||||||
|
totalBins: workload.selectedBins.length,
|
||||||
|
completedTransforms,
|
||||||
|
totalTransforms,
|
||||||
|
spectrumBatch: Math.floor(batchStart / batchSize) + 1,
|
||||||
|
spectrumBatchCount,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await yieldToEventLoop();
|
||||||
|
throwIfAborted(signal);
|
||||||
|
|
||||||
|
for (const bin of batchBins) {
|
||||||
|
const binMap = await computeBinMap({
|
||||||
|
bin,
|
||||||
|
spectrum: stft.spectra.get(bin),
|
||||||
|
frameCount: stft.frameCount,
|
||||||
|
arrayDefinition,
|
||||||
|
pointCount: workload.pointCount,
|
||||||
|
distances,
|
||||||
|
sampleRate,
|
||||||
|
fftSize: experiment.stft.fftSize,
|
||||||
|
speedOfSound: scene.speedOfSound,
|
||||||
|
signal,
|
||||||
|
pointBatchSize,
|
||||||
|
});
|
||||||
|
for (const bandIndex of bandIndexesByBin.get(bin)) {
|
||||||
|
const rawMap = rawMaps[bandIndex];
|
||||||
|
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
|
||||||
|
rawMap[pointIndex] += binMap[pointIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completedBins += 1;
|
||||||
|
onProgress?.({ stage: "beamforming", completedBins, totalBins: workload.selectedBins.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bands = experiment.frequencyBands.map((band, bandIndex) => {
|
||||||
|
const rawMap = rawMaps[bandIndex];
|
||||||
|
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
|
||||||
|
rawMap[pointIndex] /= workload.bandBins[bandIndex].length;
|
||||||
|
}
|
||||||
|
const rawPeak = findPeak(rawMap);
|
||||||
|
const displayMap = Float32Array.from(rawMap);
|
||||||
|
let displayScale = "power";
|
||||||
|
if (experiment.beamforming.normalization === "peak" && rawPeak.value > 0) {
|
||||||
|
displayScale = "normalized";
|
||||||
|
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
|
||||||
|
displayMap[pointIndex] /= rawPeak.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: band.id,
|
||||||
|
color: band.color,
|
||||||
|
bins: workload.bandBins[bandIndex],
|
||||||
|
rawMap,
|
||||||
|
displayMap,
|
||||||
|
displayScale,
|
||||||
|
peak: {
|
||||||
|
index: rawPeak.index,
|
||||||
|
position: scanIndexToPosition(rawPeak.index, experiment.scan),
|
||||||
|
rawPower: rawPeak.value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
backend: "cpu",
|
||||||
|
frameCount: workload.frameCount,
|
||||||
|
microphoneCount: workload.microphoneCount,
|
||||||
|
pointCount: workload.pointCount,
|
||||||
|
workload,
|
||||||
|
fftButterflyCount,
|
||||||
|
bands,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
function assertPowerOfTwo(value) {
|
||||||
|
if (!Number.isInteger(value) || value < 2 || (value & (value - 1)) !== 0) {
|
||||||
|
throw new Error(`FFT 크기는 2 이상의 2의 거듭제곱이어야 합니다: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fftInPlace(real, imaginary, inverse = false) {
|
||||||
|
const size = real.length;
|
||||||
|
if (imaginary.length !== size) throw new Error("FFT real/imaginary 길이가 다릅니다.");
|
||||||
|
assertPowerOfTwo(size);
|
||||||
|
|
||||||
|
for (let index = 1, reversed = 0; index < size; index += 1) {
|
||||||
|
let bit = size >> 1;
|
||||||
|
while (reversed & bit) {
|
||||||
|
reversed ^= bit;
|
||||||
|
bit >>= 1;
|
||||||
|
}
|
||||||
|
reversed ^= bit;
|
||||||
|
if (index < reversed) {
|
||||||
|
[real[index], real[reversed]] = [real[reversed], real[index]];
|
||||||
|
[imaginary[index], imaginary[reversed]] = [imaginary[reversed], imaginary[index]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = inverse ? 1 : -1;
|
||||||
|
for (let length = 2; length <= size; length <<= 1) {
|
||||||
|
const angle = (direction * 2 * Math.PI) / length;
|
||||||
|
const stepReal = Math.cos(angle);
|
||||||
|
const stepImaginary = Math.sin(angle);
|
||||||
|
for (let start = 0; start < size; start += length) {
|
||||||
|
let twiddleReal = 1;
|
||||||
|
let twiddleImaginary = 0;
|
||||||
|
const halfLength = length >> 1;
|
||||||
|
for (let offset = 0; offset < halfLength; offset += 1) {
|
||||||
|
const evenIndex = start + offset;
|
||||||
|
const oddIndex = evenIndex + halfLength;
|
||||||
|
const oddReal = real[oddIndex] * twiddleReal - imaginary[oddIndex] * twiddleImaginary;
|
||||||
|
const oddImaginary = real[oddIndex] * twiddleImaginary + imaginary[oddIndex] * twiddleReal;
|
||||||
|
real[oddIndex] = real[evenIndex] - oddReal;
|
||||||
|
imaginary[oddIndex] = imaginary[evenIndex] - oddImaginary;
|
||||||
|
real[evenIndex] += oddReal;
|
||||||
|
imaginary[evenIndex] += oddImaginary;
|
||||||
|
const nextTwiddleReal = twiddleReal * stepReal - twiddleImaginary * stepImaginary;
|
||||||
|
twiddleImaginary = twiddleReal * stepImaginary + twiddleImaginary * stepReal;
|
||||||
|
twiddleReal = nextTwiddleReal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inverse) {
|
||||||
|
for (let index = 0; index < size; index += 1) {
|
||||||
|
real[index] /= size;
|
||||||
|
imaginary[index] /= size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fftReal(samples) {
|
||||||
|
const real = Float64Array.from(samples);
|
||||||
|
const imaginary = new Float64Array(real.length);
|
||||||
|
fftInPlace(real, imaginary);
|
||||||
|
return { real, imaginary };
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { fftInPlace } from "./fft.js";
|
||||||
|
import { TAU } from "./math.js";
|
||||||
|
|
||||||
|
export function nextPowerOfTwo(value) {
|
||||||
|
if (!Number.isFinite(value) || value < 1) throw new Error(`유효하지 않은 FFT 길이: ${value}`);
|
||||||
|
let result = 1;
|
||||||
|
while (result < value) result *= 2;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareDelaySpectrum(samples, maximumDelaySamples = 0) {
|
||||||
|
const fftSize = nextPowerOfTwo(samples.length + Math.ceil(maximumDelaySamples) + 2);
|
||||||
|
const real = new Float64Array(fftSize);
|
||||||
|
const imaginary = new Float64Array(fftSize);
|
||||||
|
real.set(samples);
|
||||||
|
fftInPlace(real, imaginary);
|
||||||
|
return { real, imaginary, fftSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDelayWorkspace(fftSize) {
|
||||||
|
return { real: new Float64Array(fftSize), imaginary: new Float64Array(fftSize) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addFrequencyDomainDelay(options) {
|
||||||
|
const { output, spectrum, delaySamples, gain = 1, phaseOffsetDeg = 0, workspace } = options;
|
||||||
|
if (!(delaySamples >= 0) || !Number.isFinite(delaySamples)) throw new Error(`유효하지 않은 delay: ${delaySamples}`);
|
||||||
|
if (workspace.real.length !== spectrum.fftSize || workspace.imaginary.length !== spectrum.fftSize) {
|
||||||
|
throw new Error("fractional-delay workspace 크기가 spectrum과 다릅니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = spectrum.fftSize;
|
||||||
|
const calibrationPhase = (phaseOffsetDeg * Math.PI) / 180;
|
||||||
|
for (let bin = 0; bin < size; bin += 1) {
|
||||||
|
const signedBin = bin <= size / 2 ? bin : bin - size;
|
||||||
|
const phaseSign = signedBin === 0 ? 0 : signedBin > 0 ? 1 : -1;
|
||||||
|
const phase = (-TAU * signedBin * delaySamples) / size + phaseSign * calibrationPhase;
|
||||||
|
const phaseReal = Math.cos(phase);
|
||||||
|
const phaseImaginary = Math.sin(phase);
|
||||||
|
const inputReal = spectrum.real[bin];
|
||||||
|
const inputImaginary = spectrum.imaginary[bin];
|
||||||
|
workspace.real[bin] = inputReal * phaseReal - inputImaginary * phaseImaginary;
|
||||||
|
workspace.imaginary[bin] = inputReal * phaseImaginary + inputImaginary * phaseReal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real sequence has a real-valued Nyquist coefficient. Projecting this single self-conjugate
|
||||||
|
// bin back to the real axis preserves Hermitian symmetry for fractional delays.
|
||||||
|
workspace.imaginary[size / 2] = 0;
|
||||||
|
fftInPlace(workspace.real, workspace.imaginary, true);
|
||||||
|
const outputLength = Math.min(output.length, workspace.real.length);
|
||||||
|
for (let sampleIndex = 0; sampleIndex < outputLength; sampleIndex += 1) {
|
||||||
|
output[sampleIndex] += workspace.real[sampleIndex] * gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export * from "./das.js";
|
||||||
|
export * from "./fft.js";
|
||||||
|
export * from "./fractional-delay.js";
|
||||||
|
export * from "./math.js";
|
||||||
|
export * from "./noise.js";
|
||||||
|
export * from "./propagation.js";
|
||||||
|
export * from "./signal.js";
|
||||||
|
export * from "./stft.js";
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export const TAU = Math.PI * 2;
|
||||||
|
|
||||||
|
export function distance3(a, b) {
|
||||||
|
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dbToLinear(decibels) {
|
||||||
|
return 10 ** (decibels / 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function powerToDb(power, floorDb = -120) {
|
||||||
|
if (!(power > 0)) return floorDb;
|
||||||
|
return Math.max(floorDb, 10 * Math.log10(power));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clamp(value, minimum, maximum) {
|
||||||
|
return Math.min(maximum, Math.max(minimum, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lerp(start, end, amount) {
|
||||||
|
return start + (end - start) * amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScanPoints(scan) {
|
||||||
|
const points = new Float32Array(scan.columns * scan.rows * 4);
|
||||||
|
let offset = 0;
|
||||||
|
for (let row = 0; row < scan.rows; row += 1) {
|
||||||
|
const y = scan.rows === 1 ? scan.yMax : lerp(scan.yMax, scan.yMin, row / (scan.rows - 1));
|
||||||
|
for (let column = 0; column < scan.columns; column += 1) {
|
||||||
|
const x = scan.columns === 1 ? scan.xMin : lerp(scan.xMin, scan.xMax, column / (scan.columns - 1));
|
||||||
|
points[offset] = x;
|
||||||
|
points[offset + 1] = y;
|
||||||
|
points[offset + 2] = scan.z;
|
||||||
|
points[offset + 3] = 0;
|
||||||
|
offset += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanIndexToPosition(index, scan) {
|
||||||
|
const row = Math.floor(index / scan.columns);
|
||||||
|
const column = index % scan.columns;
|
||||||
|
return [
|
||||||
|
scan.columns === 1 ? scan.xMin : lerp(scan.xMin, scan.xMax, column / (scan.columns - 1)),
|
||||||
|
scan.rows === 1 ? scan.yMax : lerp(scan.yMax, scan.yMin, row / (scan.rows - 1)),
|
||||||
|
scan.z,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPeak(values) {
|
||||||
|
if (values.length === 0) throw new Error("빈 map에서는 peak를 찾을 수 없습니다.");
|
||||||
|
let peakIndex = 0;
|
||||||
|
let peakValue = values[0];
|
||||||
|
for (let index = 1; index < values.length; index += 1) {
|
||||||
|
if (values[index] > peakValue) {
|
||||||
|
peakValue = values[index];
|
||||||
|
peakIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { index: peakIndex, value: peakValue };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
function mix32(value) {
|
||||||
|
let mixed = value >>> 0;
|
||||||
|
mixed ^= mixed >>> 16;
|
||||||
|
mixed = Math.imul(mixed, 0x7feb352d);
|
||||||
|
mixed ^= mixed >>> 15;
|
||||||
|
mixed = Math.imul(mixed, 0x846ca68b);
|
||||||
|
mixed ^= mixed >>> 16;
|
||||||
|
return mixed >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashString(value) {
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
hash ^= value.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function whiteNoiseAt(seed, sampleIndex) {
|
||||||
|
const mixed = mix32((seed >>> 0) ^ Math.imul(sampleIndex + 1, 0x9e3779b1));
|
||||||
|
return (mixed / 0xffffffff) * 2 - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pinkNoiseAt(seed, sampleIndex) {
|
||||||
|
let total = 0;
|
||||||
|
let weight = 0;
|
||||||
|
for (let octave = 0; octave < 8; octave += 1) {
|
||||||
|
const octaveWeight = 1 / (octave + 1);
|
||||||
|
total += whiteNoiseAt(seed + octave * 0x1f123bb5, sampleIndex >> octave) * octaveWeight;
|
||||||
|
weight += octaveWeight;
|
||||||
|
}
|
||||||
|
return total / weight;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { dbToLinear, distance3 } from "./math.js";
|
||||||
|
import {
|
||||||
|
addFrequencyDomainDelay,
|
||||||
|
createDelayWorkspace,
|
||||||
|
nextPowerOfTwo,
|
||||||
|
prepareDelaySpectrum,
|
||||||
|
} from "./fractional-delay.js";
|
||||||
|
import { renderSignal } from "./signal.js";
|
||||||
|
|
||||||
|
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
|
||||||
|
return distance3(sourcePosition, receiverPosition) / speedOfSound;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sphericalAttenuation(distance, referenceDistance = 1, minimumDistance = 0.01) {
|
||||||
|
return referenceDistance / Math.max(distance, minimumDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function synthesizeMicrophoneSignals(options) {
|
||||||
|
const {
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
assetBuffers = new Map(),
|
||||||
|
referenceDistance = 1,
|
||||||
|
minimumDistance = 0.01,
|
||||||
|
maximumBufferBytes = 512 * 1024 * 1024,
|
||||||
|
} = options;
|
||||||
|
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
||||||
|
const retainedBufferCount = arrayDefinition.microphones.length + scene.sources.length + 1;
|
||||||
|
let maximumDelaySamples = 0;
|
||||||
|
for (const source of scene.sources) {
|
||||||
|
for (const microphone of arrayDefinition.microphones) {
|
||||||
|
maximumDelaySamples = Math.max(
|
||||||
|
maximumDelaySamples,
|
||||||
|
(distance3(source.position, microphone.position) / scene.speedOfSound) * scene.sampleRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const delayFftSize = nextPowerOfTwo(sampleCount + Math.ceil(maximumDelaySamples) + 2);
|
||||||
|
const delayWorkingBytes = delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4;
|
||||||
|
const estimatedBufferBytes =
|
||||||
|
retainedBufferCount * sampleCount * Float32Array.BYTES_PER_ELEMENT + delayWorkingBytes;
|
||||||
|
if (estimatedBufferBytes > maximumBufferBytes) {
|
||||||
|
throw new Error(
|
||||||
|
`예상 signal buffer ${(estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
||||||
|
`현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const channels = arrayDefinition.microphones.map(() => new Float32Array(sampleCount));
|
||||||
|
const renderedSources = scene.sources.map((source) => ({
|
||||||
|
definition: source,
|
||||||
|
samples: renderSignal(source, scene, assetBuffers),
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderedSources.forEach(({ definition, samples }) => {
|
||||||
|
const distances = arrayDefinition.microphones.map((microphone) => distance3(definition.position, microphone.position));
|
||||||
|
const maximumDelaySamples = (Math.max(...distances) / scene.speedOfSound) * scene.sampleRate;
|
||||||
|
const spectrum = prepareDelaySpectrum(samples, maximumDelaySamples);
|
||||||
|
const workspace = createDelayWorkspace(spectrum.fftSize);
|
||||||
|
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
|
||||||
|
const distance = distances[microphoneIndex];
|
||||||
|
const delaySamples = (distance / scene.speedOfSound) * scene.sampleRate;
|
||||||
|
const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0);
|
||||||
|
const amplitude = sphericalAttenuation(distance, referenceDistance, minimumDistance) * sensitivity;
|
||||||
|
addFrequencyDomainDelay({
|
||||||
|
output: channels[microphoneIndex],
|
||||||
|
spectrum,
|
||||||
|
delaySamples,
|
||||||
|
gain: amplitude,
|
||||||
|
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
||||||
|
workspace,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(scene.ambient)) {
|
||||||
|
scene.ambient.forEach((ambient, ambientIndex) => {
|
||||||
|
channels.forEach((channel, microphoneIndex) => {
|
||||||
|
const microphone = arrayDefinition.microphones[microphoneIndex];
|
||||||
|
const seedSalt = Math.imul(ambientIndex + 1, 0x45d9f3b) ^ Math.imul(microphoneIndex + 1, 0x27d4eb2d);
|
||||||
|
const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt);
|
||||||
|
const spectrum = prepareDelaySpectrum(ambientSamples, 0);
|
||||||
|
addFrequencyDomainDelay({
|
||||||
|
output: channel,
|
||||||
|
spectrum,
|
||||||
|
delaySamples: 0,
|
||||||
|
gain: dbToLinear(microphone.sensitivityDb ?? 0),
|
||||||
|
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
||||||
|
workspace: createDelayWorkspace(spectrum.fftSize),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let preNormalizationPeak = 0;
|
||||||
|
for (const channel of channels) {
|
||||||
|
for (const value of channel) {
|
||||||
|
preNormalizationPeak = Math.max(preNormalizationPeak, Math.abs(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const appliedScale =
|
||||||
|
scene.mix?.normalization === "peak" && preNormalizationPeak > 0
|
||||||
|
? scene.mix.targetPeak / preNormalizationPeak
|
||||||
|
: 1;
|
||||||
|
let peak = 0;
|
||||||
|
let clippedSampleCount = 0;
|
||||||
|
for (const channel of channels) {
|
||||||
|
for (let sampleIndex = 0; sampleIndex < channel.length; sampleIndex += 1) {
|
||||||
|
channel[sampleIndex] *= appliedScale;
|
||||||
|
const magnitude = Math.abs(channel[sampleIndex]);
|
||||||
|
peak = Math.max(peak, magnitude);
|
||||||
|
if (magnitude > 1) clippedSampleCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
channels,
|
||||||
|
sampleRate: scene.sampleRate,
|
||||||
|
peak,
|
||||||
|
preNormalizationPeak,
|
||||||
|
appliedScale,
|
||||||
|
clippedSampleCount,
|
||||||
|
estimatedBufferBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { dbToLinear, TAU } from "./math.js";
|
||||||
|
import { hashString, pinkNoiseAt, whiteNoiseAt } from "./noise.js";
|
||||||
|
|
||||||
|
function sampleAsset(signal, assetBuffers, time) {
|
||||||
|
const asset = assetBuffers.get(signal.assetId);
|
||||||
|
if (!asset) throw new Error(`오디오 asset을 찾을 수 없습니다: ${signal.assetId}`);
|
||||||
|
const channel = asset.channels[signal.channel ?? 0];
|
||||||
|
if (!channel) throw new Error(`${signal.assetId}의 channel ${signal.channel ?? 0}을 찾을 수 없습니다.`);
|
||||||
|
let sourceIndex = time * asset.sampleRate;
|
||||||
|
if (signal.loop && channel.length > 0) sourceIndex %= channel.length;
|
||||||
|
if (sourceIndex < 0 || sourceIndex >= channel.length) return 0;
|
||||||
|
const left = Math.floor(sourceIndex);
|
||||||
|
const fraction = sourceIndex - left;
|
||||||
|
const rightValue = channel[Math.min(left + 1, channel.length - 1)];
|
||||||
|
return channel[left] + (rightValue - channel[left]) * fraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt = 0) {
|
||||||
|
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
||||||
|
const output = new Float32Array(sampleCount);
|
||||||
|
const startTime = entry.startTime ?? 0;
|
||||||
|
const duration = entry.duration ?? scene.duration;
|
||||||
|
const gain = dbToLinear(entry.gainDb ?? 0);
|
||||||
|
const startSample = Math.max(0, Math.ceil(startTime * scene.sampleRate));
|
||||||
|
const endSample = Math.min(sampleCount, Math.ceil((startTime + duration) * scene.sampleRate));
|
||||||
|
const signal = entry.signal;
|
||||||
|
const seed = (scene.seed + (signal.seedOffset ?? 0) + hashString(entry.id ?? "signal") + seedSalt) >>> 0;
|
||||||
|
|
||||||
|
for (let sampleIndex = startSample; sampleIndex < endSample; sampleIndex += 1) {
|
||||||
|
const localIndex = sampleIndex - startSample;
|
||||||
|
const localTime = sampleIndex / scene.sampleRate - startTime;
|
||||||
|
let value;
|
||||||
|
if (signal.type === "tone") {
|
||||||
|
value = Math.sin(TAU * signal.frequency * localTime + ((signal.phaseDeg ?? 0) * Math.PI) / 180);
|
||||||
|
} else if (signal.type === "noise") {
|
||||||
|
value = signal.color === "pink" ? pinkNoiseAt(seed, localIndex) : whiteNoiseAt(seed, localIndex);
|
||||||
|
} else if (signal.type === "asset") {
|
||||||
|
value = sampleAsset(signal, assetBuffers, localTime);
|
||||||
|
} else {
|
||||||
|
throw new Error(`지원하지 않는 signal type: ${signal.type}`);
|
||||||
|
}
|
||||||
|
output[sampleIndex] = value * gain;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sampleLinear(buffer, sampleIndex) {
|
||||||
|
if (sampleIndex < 0 || sampleIndex >= buffer.length) return 0;
|
||||||
|
const left = Math.floor(sampleIndex);
|
||||||
|
const fraction = sampleIndex - left;
|
||||||
|
const rightValue = buffer[Math.min(left + 1, buffer.length - 1)];
|
||||||
|
return buffer[left] + (rightValue - buffer[left]) * fraction;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { fftInPlace } from "./fft.js";
|
||||||
|
|
||||||
|
function throwIfAborted(signal) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
const error = new Error("STFT 계산이 취소되었습니다.");
|
||||||
|
error.name = "AbortError";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function yieldToEventLoop() {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hannWindow(size) {
|
||||||
|
if (!Number.isInteger(size) || size < 2) throw new Error("Hann window 크기는 2 이상이어야 합니다.");
|
||||||
|
const window = new Float64Array(size);
|
||||||
|
for (let index = 0; index < size; index += 1) {
|
||||||
|
window[index] = 0.5 - 0.5 * Math.cos((2 * Math.PI * index) / (size - 1));
|
||||||
|
}
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFrameCount(sampleCount, fftSize, hopSize) {
|
||||||
|
if (!Number.isInteger(hopSize) || hopSize < 1 || hopSize > fftSize) {
|
||||||
|
throw new Error("hopSize는 1 이상 fftSize 이하여야 합니다.");
|
||||||
|
}
|
||||||
|
return Math.max(1, 1 + Math.ceil(Math.max(0, sampleCount - fftSize) / hopSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractStftBins(channels, options) {
|
||||||
|
const { fftSize, hopSize, bins, signal, onProgress, transformBatchSize = 32 } = options;
|
||||||
|
if (!Array.isArray(channels) || channels.length === 0) throw new Error("마이크 채널이 필요합니다.");
|
||||||
|
const sampleCount = channels[0].length;
|
||||||
|
if (channels.some((channel) => channel.length !== sampleCount)) throw new Error("마이크 채널 길이가 다릅니다.");
|
||||||
|
const uniqueBins = [...new Set(bins)].sort((a, b) => a - b);
|
||||||
|
for (const bin of uniqueBins) {
|
||||||
|
if (!Number.isInteger(bin) || bin < 0 || bin > fftSize / 2) throw new Error(`유효하지 않은 FFT bin: ${bin}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameCount = getFrameCount(sampleCount, fftSize, hopSize);
|
||||||
|
const microphoneCount = channels.length;
|
||||||
|
if (!Number.isInteger(transformBatchSize) || transformBatchSize < 1) {
|
||||||
|
throw new Error("transformBatchSize는 1 이상의 정수여야 합니다.");
|
||||||
|
}
|
||||||
|
const spectra = new Map();
|
||||||
|
for (const bin of uniqueBins) {
|
||||||
|
spectra.set(bin, {
|
||||||
|
real: new Float32Array(frameCount * microphoneCount),
|
||||||
|
imaginary: new Float32Array(frameCount * microphoneCount),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const window = hannWindow(fftSize);
|
||||||
|
const real = new Float64Array(fftSize);
|
||||||
|
const imaginary = new Float64Array(fftSize);
|
||||||
|
const totalTransforms = frameCount * microphoneCount;
|
||||||
|
let completedTransforms = 0;
|
||||||
|
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
||||||
|
const frameStart = frameIndex * hopSize;
|
||||||
|
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
|
||||||
|
const channel = channels[microphoneIndex];
|
||||||
|
real.fill(0);
|
||||||
|
imaginary.fill(0);
|
||||||
|
for (let offset = 0; offset < fftSize; offset += 1) {
|
||||||
|
real[offset] = (channel[frameStart + offset] ?? 0) * window[offset];
|
||||||
|
}
|
||||||
|
fftInPlace(real, imaginary);
|
||||||
|
const spectrumIndex = frameIndex * microphoneCount + microphoneIndex;
|
||||||
|
for (const bin of uniqueBins) {
|
||||||
|
const spectrum = spectra.get(bin);
|
||||||
|
spectrum.real[spectrumIndex] = real[bin];
|
||||||
|
spectrum.imaginary[spectrumIndex] = imaginary[bin];
|
||||||
|
}
|
||||||
|
completedTransforms += 1;
|
||||||
|
if (completedTransforms % transformBatchSize === 0) {
|
||||||
|
onProgress?.({ completedTransforms, totalTransforms });
|
||||||
|
await yieldToEventLoop();
|
||||||
|
throwIfAborted(signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onProgress?.({ completedTransforms, totalTransforms });
|
||||||
|
throwIfAborted(signal);
|
||||||
|
return { frameCount, microphoneCount, spectra };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function binsForBand(band, sampleRate, fftSize) {
|
||||||
|
const binWidth = sampleRate / fftSize;
|
||||||
|
const firstBin = Math.max(0, Math.ceil(band.minHz / binWidth));
|
||||||
|
const lastBin = Math.min(fftSize / 2, Math.floor(band.maxHz / binWidth));
|
||||||
|
if (firstBin > lastBin) {
|
||||||
|
throw new Error(`${band.minHz}–${band.maxHz} Hz에 해당하는 FFT bin이 없습니다.`);
|
||||||
|
}
|
||||||
|
return Array.from({ length: lastBin - firstBin + 1 }, (_, index) => firstBin + index);
|
||||||
|
}
|
||||||
@@ -332,14 +332,31 @@ function validateExperimentRelationships(value, context = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (context.arrayDefinition && context.scene) {
|
if (context.arrayDefinition && context.scene) {
|
||||||
const estimatedBytes =
|
const retainedBufferCount = context.arrayDefinition.microphones.length + context.scene.sources.length + 1;
|
||||||
context.arrayDefinition.microphones.length * Math.ceil(context.scene.duration * context.scene.sampleRate) * 4;
|
const sampleCount = Math.ceil(context.scene.duration * context.scene.sampleRate);
|
||||||
|
let maximumDelaySamples = 0;
|
||||||
|
for (const source of context.scene.sources) {
|
||||||
|
for (const microphone of context.arrayDefinition.microphones) {
|
||||||
|
const distance = Math.hypot(
|
||||||
|
source.position[0] - microphone.position[0],
|
||||||
|
source.position[1] - microphone.position[1],
|
||||||
|
source.position[2] - microphone.position[2],
|
||||||
|
);
|
||||||
|
maximumDelaySamples = Math.max(
|
||||||
|
maximumDelaySamples,
|
||||||
|
(distance / context.scene.speedOfSound) * context.scene.sampleRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let delayFftSize = 1;
|
||||||
|
while (delayFftSize < sampleCount + Math.ceil(maximumDelaySamples) + 2) delayFftSize *= 2;
|
||||||
|
const estimatedBytes = retainedBufferCount * sampleCount * 4 + delayFftSize * 8 * 4;
|
||||||
const maximumBytes = 512 * 1024 * 1024;
|
const maximumBytes = 512 * 1024 * 1024;
|
||||||
if (estimatedBytes > maximumBytes) {
|
if (estimatedBytes > maximumBytes) {
|
||||||
addIssue(
|
addIssue(
|
||||||
issues,
|
issues,
|
||||||
"$",
|
"$",
|
||||||
`현재 비스트리밍 CPU 모델의 채널 buffer 예상 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`,
|
`현재 비스트리밍 CPU 모델의 signal buffer 예상 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { computeDasMap } from "/packages/acoustics-core/das.js";
|
||||||
|
import { distance3 } from "/packages/acoustics-core/math.js";
|
||||||
|
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
|
||||||
|
|
||||||
|
async function loadJson(path) {
|
||||||
|
const response = await fetch(path);
|
||||||
|
assert(response.ok, `${path} load failed`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("192채널 · 50×50 CPU baseline이 ground truth를 찾는다", async () => {
|
||||||
|
const [arrayDefinition, scene, experiment] = await Promise.all([
|
||||||
|
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
|
||||||
|
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
||||||
|
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||||
|
]);
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const synthesis = synthesizeMicrophoneSignals({ scene, arrayDefinition });
|
||||||
|
const result = await computeDasMap({
|
||||||
|
channels: synthesis.channels,
|
||||||
|
sampleRate: synthesis.sampleRate,
|
||||||
|
arrayDefinition,
|
||||||
|
scene,
|
||||||
|
experiment,
|
||||||
|
});
|
||||||
|
const elapsedMs = performance.now() - startedAt;
|
||||||
|
const localizationError = distance3(result.bands[0].peak.position, scene.sources[0].position);
|
||||||
|
|
||||||
|
assertEqual(result.microphoneCount, 192, "microphone count");
|
||||||
|
assertEqual(result.pointCount, 2500, "scan point count");
|
||||||
|
assert(localizationError < 0.03, `localization error=${localizationError} m`);
|
||||||
|
assert(Number.isFinite(result.bands[0].peak.rawPower) && result.bands[0].peak.rawPower > 0, "finite peak power");
|
||||||
|
document.documentElement.dataset.cpuBaselineMs = elapsedMs.toFixed(1);
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@ 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/integration/cpu-baseline.test.js");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
registeredTests.push({
|
registeredTests.push({
|
||||||
name: "테스트 모듈을 불러온다",
|
name: "테스트 모듈을 불러온다",
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { computeBinMap, computeDasMap, createDistanceMatrix } from "/packages/acoustics-core/das.js";
|
||||||
|
import { fftInPlace, fftReal } from "/packages/acoustics-core/fft.js";
|
||||||
|
import {
|
||||||
|
addFrequencyDomainDelay,
|
||||||
|
createDelayWorkspace,
|
||||||
|
prepareDelaySpectrum,
|
||||||
|
} from "/packages/acoustics-core/fractional-delay.js";
|
||||||
|
import { dbToLinear, distance3 } from "/packages/acoustics-core/math.js";
|
||||||
|
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
|
||||||
|
import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
|
||||||
|
import { getFrameCount } from "/packages/acoustics-core/stft.js";
|
||||||
|
|
||||||
|
function assertNear(actual, expected, tolerance, label) {
|
||||||
|
assert(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected} ± ${tolerance}, received ${actual}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rms(values, start = 0, end = values.length) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let index = start; index < end; index += 1) sum += values[index] ** 2;
|
||||||
|
return Math.sqrt(sum / (end - start));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createArray(columns, rows, width, height) {
|
||||||
|
const microphones = [];
|
||||||
|
for (let row = 0; row < rows; row += 1) {
|
||||||
|
for (let column = 0; column < columns; column += 1) {
|
||||||
|
microphones.push({
|
||||||
|
id: `mic-${row}-${column}`,
|
||||||
|
position: [
|
||||||
|
-width / 2 + (column * width) / (columns - 1),
|
||||||
|
-height / 2 + (row * height) / (rows - 1),
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: "test-array", aperture: { width, height }, microphones };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("거리, dB와 구면 감쇠 기본식을 계산한다", () => {
|
||||||
|
assertNear(distance3([0, 0, 0], [1, 2, 2]), 3, 1e-12, "distance");
|
||||||
|
assertNear(dbToLinear(-6.020599913), 0.5, 1e-9, "dB amplitude");
|
||||||
|
assertNear(sphericalAttenuation(2), 0.5, 1e-12, "1/r attenuation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("index 기반 noise가 seed에 대해 결정적이다", () => {
|
||||||
|
const first = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
|
||||||
|
const second = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
|
||||||
|
assert(first.every((value, index) => value === second[index]));
|
||||||
|
assert(first.some((value, index) => value !== whiteNoiseAt(43, index)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("radix-2 FFT가 정확한 tone bin을 찾는다", () => {
|
||||||
|
const size = 128;
|
||||||
|
const bin = 16;
|
||||||
|
const samples = Float64Array.from({ length: size }, (_, index) => Math.sin((2 * Math.PI * bin * index) / size));
|
||||||
|
const spectrum = fftReal(samples);
|
||||||
|
const magnitude = Math.hypot(spectrum.real[bin], spectrum.imaginary[bin]);
|
||||||
|
assertNear(magnitude, size / 2, 1e-8, "tone magnitude");
|
||||||
|
assertNear(spectrum.real[bin], 0, 1e-8, "forward real sign");
|
||||||
|
assertNear(spectrum.imaginary[bin], -size / 2, 1e-8, "forward imaginary sign");
|
||||||
|
assert(Math.hypot(spectrum.real[bin - 1], spectrum.imaginary[bin - 1]) < 1e-8, "adjacent leakage");
|
||||||
|
fftInPlace(spectrum.real, spectrum.imaginary, true);
|
||||||
|
samples.forEach((value, index) => assertNear(spectrum.real[index], value, 1e-9, `inverse sample ${index}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("FFT phase shift가 integer와 fractional delay를 적용한다", () => {
|
||||||
|
const impulse = new Float32Array(16);
|
||||||
|
impulse[2] = 1;
|
||||||
|
const impulseSpectrum = prepareDelaySpectrum(impulse, 3);
|
||||||
|
const impulseOutput = new Float32Array(16);
|
||||||
|
addFrequencyDomainDelay({
|
||||||
|
output: impulseOutput,
|
||||||
|
spectrum: impulseSpectrum,
|
||||||
|
delaySamples: 3,
|
||||||
|
workspace: createDelayWorkspace(impulseSpectrum.fftSize),
|
||||||
|
});
|
||||||
|
assertNear(impulseOutput[5], 1, 1e-6, "integer delayed impulse");
|
||||||
|
|
||||||
|
const size = 512;
|
||||||
|
const frequencyCyclesPerSample = 1 / 8;
|
||||||
|
const tone = Float32Array.from({ length: size }, (_, index) => Math.sin(2 * Math.PI * frequencyCyclesPerSample * index));
|
||||||
|
const toneSpectrum = prepareDelaySpectrum(tone, 2.5);
|
||||||
|
const delayedTone = new Float32Array(size);
|
||||||
|
addFrequencyDomainDelay({
|
||||||
|
output: delayedTone,
|
||||||
|
spectrum: toneSpectrum,
|
||||||
|
delaySamples: 2.5,
|
||||||
|
workspace: createDelayWorkspace(toneSpectrum.fftSize),
|
||||||
|
});
|
||||||
|
let squaredError = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let index = 48; index < size - 48; index += 1) {
|
||||||
|
const expected = Math.sin(2 * Math.PI * frequencyCyclesPerSample * (index - 2.5));
|
||||||
|
squaredError += (delayedTone[index] - expected) ** 2;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
assert(Math.sqrt(squaredError / count) < 1e-3, "fractional delay RMS error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("STFT frame count가 마지막 partial tail을 포함한다", () => {
|
||||||
|
assertEqual(getFrameCount(512, 512, 256), 1);
|
||||||
|
assertEqual(getFrameCount(513, 512, 256), 2);
|
||||||
|
assertEqual(getFrameCount(12000, 512, 256), 46);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("구면 전파가 거리별 지연과 1/r 진폭비를 적용한다", () => {
|
||||||
|
const scene = {
|
||||||
|
sampleRate: 8000,
|
||||||
|
duration: 0.02,
|
||||||
|
speedOfSound: 400,
|
||||||
|
seed: 1,
|
||||||
|
mix: { normalization: "none", targetPeak: 0.95 },
|
||||||
|
sources: [{
|
||||||
|
id: "tone", name: "tone", position: [0, 0, 0.1], gainDb: -40, startTime: 0, duration: 0.02,
|
||||||
|
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
|
||||||
|
}],
|
||||||
|
ambient: [],
|
||||||
|
};
|
||||||
|
const arrayDefinition = {
|
||||||
|
microphones: [
|
||||||
|
{ id: "near", position: [0, 0, 0.05] },
|
||||||
|
{ id: "far", position: [0, 0, 0] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = synthesizeMicrophoneSignals({ scene, arrayDefinition });
|
||||||
|
const emittedSampleOne = Math.sin((2 * Math.PI * 500) / 8000) * dbToLinear(-40);
|
||||||
|
assertNear(result.channels[0][1], 0, 1e-7, "one-sample propagation delay");
|
||||||
|
assertNear(result.channels[0][2], emittedSampleOne * 20, 1e-5, "near analytic delayed sample");
|
||||||
|
assertNear(result.channels[1][3], emittedSampleOne * 10, 1e-5, "far analytic delayed sample");
|
||||||
|
assertNear(rms(result.channels[0], 16, 150) / rms(result.channels[1], 16, 150), 2, 0.02, "distance amplitude ratio");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("peak mix 정규화와 channel buffer 한도를 적용한다", () => {
|
||||||
|
const scene = {
|
||||||
|
sampleRate: 8000,
|
||||||
|
duration: 0.02,
|
||||||
|
speedOfSound: 343,
|
||||||
|
seed: 1,
|
||||||
|
mix: { normalization: "peak", targetPeak: 0.5 },
|
||||||
|
sources: [{
|
||||||
|
id: "tone", name: "tone", position: [0, 0, 1], gainDb: 6, startTime: 0, duration: 0.02,
|
||||||
|
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
|
||||||
|
}],
|
||||||
|
ambient: [],
|
||||||
|
};
|
||||||
|
const arrayDefinition = { microphones: [{ id: "mic", position: [0, 0, 0] }] };
|
||||||
|
const result = synthesizeMicrophoneSignals({ scene, arrayDefinition });
|
||||||
|
assertNear(result.peak, 0.5, 1e-6, "normalized peak");
|
||||||
|
assert(result.appliedScale < 1);
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
synthesizeMicrophoneSignals({ scene, arrayDefinition, maximumBufferBytes: 1 });
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
assert(error instanceof Error && error.message.includes("한도"), "buffer limit error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ambient에도 microphone gain과 phase calibration 편차를 적용한다", () => {
|
||||||
|
const scene = {
|
||||||
|
sampleRate: 8000,
|
||||||
|
duration: 0.02,
|
||||||
|
speedOfSound: 343,
|
||||||
|
seed: 1,
|
||||||
|
mix: { normalization: "none", targetPeak: 0.95 },
|
||||||
|
sources: [],
|
||||||
|
ambient: [{ id: "ambient", gainDb: -20, signal: { type: "tone", frequency: 500, phaseDeg: 0 } }],
|
||||||
|
};
|
||||||
|
const arrayDefinition = {
|
||||||
|
microphones: [{ id: "calibrated", position: [0, 0, 0], sensitivityDb: 6, phaseOffsetDeg: 90 }],
|
||||||
|
};
|
||||||
|
const result = synthesizeMicrophoneSignals({ scene, arrayDefinition });
|
||||||
|
const expected = dbToLinear(-20) * dbToLinear(6);
|
||||||
|
assertNear(result.channels[0][80], expected, 4e-3, "ambient calibrated sample");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CPU DAS peak가 calibration 후 단일 tone의 실제 grid 위치를 찾는다", async () => {
|
||||||
|
const arrayDefinition = createArray(4, 4, 0.3, 0.3);
|
||||||
|
arrayDefinition.microphones.forEach((microphone, index) => {
|
||||||
|
microphone.sensitivityDb = (index % 3) * 2 - 2;
|
||||||
|
microphone.phaseOffsetDeg = (index % 5) * 8 - 16;
|
||||||
|
});
|
||||||
|
const scene = {
|
||||||
|
id: "das-scene",
|
||||||
|
sampleRate: 8000,
|
||||||
|
duration: 0.128,
|
||||||
|
speedOfSound: 343,
|
||||||
|
seed: 7,
|
||||||
|
mix: { normalization: "none", targetPeak: 0.95 },
|
||||||
|
sources: [{
|
||||||
|
id: "source", name: "source", position: [0.1, 0.1, 1.5], gainDb: -12, startTime: 0, duration: 0.128,
|
||||||
|
signal: { type: "tone", frequency: 1000, phaseDeg: 0 },
|
||||||
|
}],
|
||||||
|
ambient: [],
|
||||||
|
};
|
||||||
|
const experiment = {
|
||||||
|
stft: { fftSize: 256, hopSize: 128, window: "hann" },
|
||||||
|
scan: { z: 1.5, xMin: -0.4, xMax: 0.4, yMin: -0.4, yMax: 0.4, columns: 17, rows: 17 },
|
||||||
|
frequencyBands: [{ id: "1k", minHz: 990, maxHz: 1010, color: "#65f2bf" }],
|
||||||
|
beamforming: { normalization: "peak" },
|
||||||
|
};
|
||||||
|
const synthesis = synthesizeMicrophoneSignals({ scene, arrayDefinition });
|
||||||
|
const progressEvents = [];
|
||||||
|
const result = await computeDasMap({
|
||||||
|
channels: synthesis.channels,
|
||||||
|
sampleRate: synthesis.sampleRate,
|
||||||
|
arrayDefinition,
|
||||||
|
scene,
|
||||||
|
experiment,
|
||||||
|
onProgress: (progress) => progressEvents.push(progress),
|
||||||
|
});
|
||||||
|
const peakPosition = result.bands[0].peak.position;
|
||||||
|
assert(distance3(peakPosition, scene.sources[0].position) <= 0.071, `peak=${peakPosition.join(",")}`);
|
||||||
|
assert(result.bands[0].rawMap !== result.bands[0].displayMap, "raw/display buffers are separate");
|
||||||
|
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");
|
||||||
|
assert(progressEvents.some(({ stage }) => stage === "beamforming"), "beamforming progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("독립 복소 spectrum fixture에서 DAS steering 부호가 맞다", async () => {
|
||||||
|
const arrayDefinition = {
|
||||||
|
microphones: [
|
||||||
|
{ id: "left", position: [-0.15, 0, 0] },
|
||||||
|
{ id: "middle", position: [-0.02, 0, 0] },
|
||||||
|
{ id: "right", position: [0.12, 0, 0] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const scan = { z: 1, xMin: -0.2, xMax: 0.2, yMin: 0, yMax: 0, columns: 2, rows: 1 };
|
||||||
|
const target = [0.2, 0, 1];
|
||||||
|
const sampleRate = 8000;
|
||||||
|
const fftSize = 256;
|
||||||
|
const bin = 32;
|
||||||
|
const frequency = (bin * sampleRate) / fftSize;
|
||||||
|
const speedOfSound = 343;
|
||||||
|
const spectrum = { real: new Float32Array(3), imaginary: new Float32Array(3) };
|
||||||
|
arrayDefinition.microphones.forEach((microphone, index) => {
|
||||||
|
const phase = (2 * Math.PI * frequency * distance3(target, microphone.position)) / speedOfSound;
|
||||||
|
spectrum.real[index] = Math.cos(-phase);
|
||||||
|
spectrum.imaginary[index] = Math.sin(-phase);
|
||||||
|
});
|
||||||
|
const map = await computeBinMap({
|
||||||
|
bin,
|
||||||
|
spectrum,
|
||||||
|
frameCount: 1,
|
||||||
|
arrayDefinition,
|
||||||
|
pointCount: 2,
|
||||||
|
distances: createDistanceMatrix(arrayDefinition, scan),
|
||||||
|
sampleRate,
|
||||||
|
fftSize,
|
||||||
|
speedOfSound,
|
||||||
|
pointBatchSize: 64,
|
||||||
|
});
|
||||||
|
assertNear(map[1], 1, 1e-6, "target coherent power");
|
||||||
|
assert(map[1] > map[0] * 1.1, `wrong-sign alternative power=${map[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CPU DAS가 event-loop 취소와 working-set 한도를 지킨다", async () => {
|
||||||
|
const arrayDefinition = createArray(2, 2, 0.2, 0.2);
|
||||||
|
const scene = { speedOfSound: 343 };
|
||||||
|
const experiment = {
|
||||||
|
stft: { fftSize: 128, hopSize: 64, window: "hann" },
|
||||||
|
scan: { z: 1, xMin: -0.2, xMax: 0.2, yMin: -0.2, yMax: 0.2, columns: 17, rows: 17 },
|
||||||
|
frequencyBands: [{ id: "tone", minHz: 490, maxHz: 510, color: "#65f2bf" }],
|
||||||
|
beamforming: { normalization: "peak" },
|
||||||
|
};
|
||||||
|
const channels = arrayDefinition.microphones.map(() => new Float32Array(512));
|
||||||
|
let memoryError;
|
||||||
|
try {
|
||||||
|
await computeDasMap({ channels, sampleRate: 8000, arrayDefinition, scene, experiment, maximumWorkingBytes: 1 });
|
||||||
|
} catch (error) {
|
||||||
|
memoryError = error;
|
||||||
|
}
|
||||||
|
assert(memoryError instanceof Error && memoryError.message.includes("working set"), "working-set guard");
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
let stftStarted = false;
|
||||||
|
let abortScheduled = false;
|
||||||
|
const pending = computeDasMap({
|
||||||
|
channels,
|
||||||
|
sampleRate: 8000,
|
||||||
|
arrayDefinition,
|
||||||
|
scene,
|
||||||
|
experiment,
|
||||||
|
signal: controller.signal,
|
||||||
|
pointBatchSize: 1,
|
||||||
|
stftTransformBatchSize: 1,
|
||||||
|
onProgress: (progress) => {
|
||||||
|
if (progress.stage === "stft" && progress.completedTransforms > 0 && !abortScheduled) {
|
||||||
|
stftStarted = true;
|
||||||
|
abortScheduled = true;
|
||||||
|
setTimeout(() => controller.abort(), 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let abortError;
|
||||||
|
try {
|
||||||
|
await pending;
|
||||||
|
} catch (error) {
|
||||||
|
abortError = error;
|
||||||
|
}
|
||||||
|
assert(stftStarted, "STFT started before abort");
|
||||||
|
assert(abortError?.name === "AbortError", "async abort");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user