feat: implement free-field CPU beamforming

This commit is contained in:
Codex
2026-08-28 09:48:34 +09:00
parent 691a4b03ce
commit 6a1197d60e
15 changed files with 1148 additions and 4 deletions
+22 -1
View File
@@ -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 검출까지 실행합니다.
+270
View File
@@ -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,
};
}
+63
View File
@@ -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;
}
}
+8
View File
@@ -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";
+62
View File
@@ -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 };
}
+34
View File
@@ -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;
}
+123
View File
@@ -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,
};
}
+53
View File
@@ -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;
}
+96
View File
@@ -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);
}