feat: implement free-field CPU beamforming
This commit is contained in:
@@ -1,3 +1,24 @@
|
||||
# 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);
|
||||
}
|
||||
Reference in New Issue
Block a user