149 lines
5.3 KiB
JavaScript
149 lines
5.3 KiB
JavaScript
import { fftInPlace } from "./fft.js";
|
|
import { cooperativeYield } from "./cooperative-yield.js";
|
|
import { observeFrequencyBand } from "./sampling.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 function getCompleteFrameCount(sampleCount, fftSize, hopSize) {
|
|
if (!Number.isInteger(hopSize) || hopSize < 1 || hopSize > fftSize) {
|
|
throw new Error("hopSize는 1 이상 fftSize 이하여야 합니다.");
|
|
}
|
|
if (!Number.isInteger(sampleCount) || sampleCount < 0) {
|
|
throw new RangeError("sampleCount must be a non-negative integer.");
|
|
}
|
|
if (sampleCount < fftSize) return 0;
|
|
return 1 + Math.floor((sampleCount - fftSize) / hopSize);
|
|
}
|
|
|
|
export async function extractStftBins(channels, options) {
|
|
const {
|
|
fftSize,
|
|
hopSize,
|
|
bins,
|
|
signal,
|
|
onProgress,
|
|
transformBatchSize = 32,
|
|
workspace: providedWorkspace,
|
|
spectrumPrecision = "float32",
|
|
includePartialFrame = true,
|
|
} = 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}`);
|
|
}
|
|
|
|
if (typeof includePartialFrame !== "boolean") {
|
|
throw new TypeError("includePartialFrame must be boolean.");
|
|
}
|
|
const frameCount = includePartialFrame
|
|
? getFrameCount(sampleCount, fftSize, hopSize)
|
|
: getCompleteFrameCount(sampleCount, fftSize, hopSize);
|
|
const microphoneCount = channels.length;
|
|
if (!Number.isInteger(transformBatchSize) || transformBatchSize < 1) {
|
|
throw new Error("transformBatchSize는 1 이상의 정수여야 합니다.");
|
|
}
|
|
if (spectrumPrecision !== "float32" && spectrumPrecision !== "float64") {
|
|
throw new RangeError('spectrumPrecision must be "float32" or "float64".');
|
|
}
|
|
const SpectrumArray = spectrumPrecision === "float64"
|
|
? Float64Array
|
|
: Float32Array;
|
|
const spectra = new Map();
|
|
for (const bin of uniqueBins) {
|
|
spectra.set(bin, {
|
|
real: new SpectrumArray(frameCount * microphoneCount),
|
|
imaginary: new SpectrumArray(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, spectrumPrecision };
|
|
}
|
|
|
|
export function binsForBand(band, sampleRate, fftSize) {
|
|
return observeFrequencyBand(band, sampleRate, fftSize).bins;
|
|
}
|