Files
micarrsim/packages/acoustics-core/stft.js
T
2026-08-28 18:58:48 +09:00

130 lines
4.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { fftInPlace } from "./fft.js";
import { cooperativeYield } from "./cooperative-yield.js";
function throwIfAborted(signal) {
if (signal?.aborted) {
const error = new Error("STFT 계산이 취소되었습니다.");
error.name = "AbortError";
throw error;
}
}
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;
}
const STFT_WORKSPACE_KIND = "micarrsim.stft-workspace/1";
export function createStftWorkspace(fftSize) {
return {
kind: STFT_WORKSPACE_KIND,
fftSize,
window: hannWindow(fftSize),
real: new Float64Array(fftSize),
imaginary: new Float64Array(fftSize),
};
}
function resolveStftWorkspace(fftSize, workspace) {
if (workspace === undefined) return createStftWorkspace(fftSize);
if (
workspace?.kind !== STFT_WORKSPACE_KIND ||
workspace.fftSize !== fftSize ||
workspace.window?.length !== fftSize ||
workspace.real?.length !== fftSize ||
workspace.imaginary?.length !== fftSize
) {
throw new RangeError("STFT workspace must match fftSize.");
}
return workspace;
}
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,
workspace: providedWorkspace,
} = 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 workspace = resolveStftWorkspace(fftSize, providedWorkspace);
const { window, real, imaginary } = workspace;
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 &&
completedTransforms < totalTransforms
) {
onProgress?.({ completedTransforms, totalTransforms });
await cooperativeYield();
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);
}