Files
micarrsim/tests/unit/acoustics.test.js
T
2026-08-28 18:58:48 +09:00

805 lines
33 KiB
JavaScript

import { computeBinMap, computeDasMap, createDistanceMatrix } from "/packages/acoustics-core/das.js";
import { fftInPlace, fftReal } from "/packages/acoustics-core/fft.js";
import {
buildAcousticPaths,
maximumAcousticDelaySeconds,
} from "/packages/acoustics-core/environment.js";
import { interpolateFrequencyResponse } from "/packages/acoustics-core/frequency-response.js";
import {
addFrequencyDomainDelay,
addFrequencyDomainPaths,
createDelayWorkspace,
prepareDelaySpectrum,
} from "/packages/acoustics-core/fractional-delay.js";
import { dbToLinear, distance3 } from "/packages/acoustics-core/math.js";
import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
import { renderSignal } from "/packages/acoustics-core/signal.js";
import { getFrameCount } from "/packages/acoustics-core/stft.js";
import {
createQuasiStaticWindows,
sampleQuasiStaticWindow,
sceneAtTime,
sourcePositionAtTime,
} from "/packages/acoustics-core/trajectory.js";
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.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("trajectory 위치를 선형 보간하고 시간창 중앙에서 준정적으로 샘플링한다", () => {
const source = {
position: [9, 8, 7],
trajectory: [
{ time: 0.2, position: [-1, 0, 2] },
{ time: 0.6, position: [1, 2, 4] },
{ time: 1, position: [3, 0, 2] },
],
};
assertEqual(sourcePositionAtTime({ position: [4, 5, 6] }, 0.5).join(","), "4,5,6", "fixed fallback");
assertEqual(sourcePositionAtTime(source, 0).join(","), "-1,0,2", "before first keyframe");
assertEqual(sourcePositionAtTime(source, 1.5).join(","), "3,0,2", "after last keyframe");
const interpolated = sourcePositionAtTime(source, 0.4);
assertNear(interpolated[0], 0, 1e-12, "interpolated x");
assertNear(interpolated[1], 1, 1e-12, "interpolated y");
assertNear(interpolated[2], 3, 1e-12, "interpolated z");
const scene = { duration: 1, sources: [source] };
const sampled = sceneAtTime(scene, 0.4);
assert(sampled !== scene && sampled.sources[0] !== source, "sceneAtTime is immutable");
assertEqual(source.position.join(","), "9,8,7", "original fallback position is unchanged");
assertEqual(sampled.sources[0].trajectory, source.trajectory, "trajectory is preserved");
assertNear(sampled.sources[0].position[0], 0, 1e-12, "sampled scene x");
assertNear(sampled.sources[0].position[1], 1, 1e-12, "sampled scene y");
assertNear(sampled.sources[0].position[2], 3, 1e-12, "sampled scene z");
const windows = createQuasiStaticWindows(scene, { windowDuration: 0.4, hopDuration: 0.4 });
assertEqual(windows.length, 3, "partial final window");
assertNear(windows[0].sampleTime, 0.2, 1e-12, "first midpoint");
assertNear(windows[1].sampleTime, 0.6, 1e-12, "second midpoint");
assertNear(windows[2].sampleTime, 0.9, 1e-12, "partial midpoint");
assertNear(windows[2].duration, 0.2, 1e-12, "partial duration");
assertNear(windows[1].scene.sources[0].position[0], 1, 1e-12, "window x");
assertNear(windows[1].scene.sources[0].position[1], 2, 1e-12, "window y");
assertNear(windows[1].scene.sources[0].position[2], 4, 1e-12, "window z");
});
test("trajectory가 없는 sceneAtTime snapshot은 기존 합성 결과와 sample 단위로 동일하다", () => {
const scene = {
sampleRate: 8000,
duration: 0.01,
speedOfSound: 343,
seed: 17,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "fixed", name: "fixed", position: [0.1, -0.2, 1.2], gainDb: -12, startTime: 0, duration: 0.01,
signal: { type: "tone", frequency: 750, phaseDeg: 35 },
}],
ambient: [],
};
const arrayDefinition = {
microphones: [
{ id: "left", position: [-0.1, 0, 0] },
{ id: "right", position: [0.1, 0, 0], sensitivityDb: -1, phaseOffsetDeg: 3 },
],
};
const baseline = synthesizeMicrophoneSignals({ scene, arrayDefinition });
const sampled = synthesizeMicrophoneSignals({ scene: sceneAtTime(scene, 0.007), arrayDefinition });
assertEqual(sampled.appliedScale, baseline.appliedScale, "applied scale");
assertEqual(sampled.preNormalizationPeak, baseline.preNormalizationPeak, "pre-normalization peak");
baseline.channels.forEach((channel, channelIndex) => {
channel.forEach((value, sampleIndex) => {
assertEqual(sampled.channels[channelIndex][sampleIndex], value, `channel ${channelIndex} sample ${sampleIndex}`);
});
});
});
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("자유음장 경로 계약은 기존 직접음 delay와 1/r gain을 보존한다", () => {
const scene = { speedOfSound: 400, seed: 11 };
const paths = buildAcousticPaths({
scene,
sourcePosition: [0, 0, 2],
receiverPosition: [0, 0, 0],
sourceId: "source",
receiverId: "mic",
});
assertEqual(paths.length, 1, "direct path only");
assertEqual(paths[0].kind, "direct", "path kind");
assertNear(paths[0].delaySeconds, 0.005, 1e-12, "direct delay");
assertNear(paths[0].gain, 0.5, 1e-12, "direct spherical gain");
});
test("shoebox가 1차 반사와 산란을 고정된 sparse path로 만든다", () => {
const scene = {
speedOfSound: 343,
seed: 23,
sources: [{ id: "source", position: [0, 0, 1] }],
acoustics: {
model: "shoebox",
room: { min: [-2, -2, -1], max: [2, 2, 3] },
reflections: { order: 1, wallGainDb: -6, scattering: 0.25 },
diffuseReverb: { enabled: true, rt60Seconds: 0.2, gainDb: -18, tapCount: 4 },
},
};
const options = {
scene,
sourcePosition: scene.sources[0].position,
receiverPosition: [0, 0, 0],
sourceId: "source",
receiverId: "mic",
};
const first = buildAcousticPaths(options);
const second = buildAcousticPaths(options);
assertEqual(first.length, 17, "direct + six reflection + six scatter + four diffuse");
assertEqual(first.filter(({ kind }) => kind === "reflection").length, 6, "first-order reflection count");
assertEqual(first.filter(({ kind }) => kind === "scattering").length, 6, "scattering count");
assertEqual(first.filter(({ kind }) => kind === "diffuse").length, 4, "diffuse count");
assertEqual(JSON.stringify(first), JSON.stringify(second), "seeded path set is deterministic");
const maximum = maximumAcousticDelaySeconds({
scene,
arrayDefinition: { microphones: [{ id: "mic", position: [0, 0, 0] }] },
});
assertNear(maximum, Math.max(...first.map(({ delaySeconds }) => delaySeconds)), 1e-12, "maximum delay");
});
test("차폐물은 직접음을 감쇠하고 더 긴 edge diffraction 경로를 추가한다", () => {
const scene = {
speedOfSound: 400,
seed: 1,
acoustics: {
model: "shoebox",
room: { min: [-2, -2, -2], max: [2, 2, 2] },
occluders: [{
id: "panel",
min: [-0.1, -0.5, -0.5],
max: [0.1, 0.5, 0.5],
transmissionDb: -20,
diffractionGainDb: -12,
}],
},
};
const paths = buildAcousticPaths({
scene,
sourcePosition: [-1, 0, 0],
receiverPosition: [1, 0, 0],
});
assertEqual(paths.length, 2, "direct and diffraction paths");
const direct = paths.find(({ kind }) => kind === "direct");
const diffraction = paths.find(({ kind }) => kind === "diffraction");
assertNear(direct.gain, 0.05, 1e-12, "-20 dB direct transmission");
assert(diffraction.distance > direct.distance, "edge detour is longer than line of sight");
assert(diffraction.delaySeconds > direct.delaySeconds, "diffraction arrives later");
});
test("마이크 주파수 응답을 dB와 wrapped phase로 보간하고 끝점 밖에서 고정한다", () => {
const curve = [
{ frequencyHz: 100, gainDb: -6, phaseDeg: 170 },
{ frequencyHz: 300, gainDb: 6, phaseDeg: -170 },
];
const below = interpolateFrequencyResponse(curve, 20);
const middle = interpolateFrequencyResponse(curve, 200);
const above = interpolateFrequencyResponse(curve, 10_000);
assertEqual(below.gainDb, -6, "lower endpoint gain");
assertEqual(below.phaseDeg, 170, "lower endpoint phase");
assertNear(middle.gainDb, 0, 1e-12, "interpolated dB");
assertNear(Math.abs(middle.phaseDeg), 180, 1e-12, "phase follows shortest wrapped path");
assertNear(middle.linearGain, 1, 1e-12, "interpolated linear gain");
assertEqual(above.gainDb, 6, "upper endpoint gain");
assertEqual(above.phaseDeg, -170, "upper endpoint phase");
});
test("DAS 역응답은 깊은 notch의 boost를 기본 24 dB로 제한한다", async () => {
const arrayDefinition = {
microphones: [{
id: "notched",
position: [0, 0, 0],
frequencyResponse: [
{ frequencyHz: 0, gainDb: -120 },
{ frequencyHz: 4000, gainDb: -120 },
],
}],
};
const options = {
bin: 8,
spectrum: { real: Float32Array.of(1), imaginary: Float32Array.of(0) },
frameCount: 1,
arrayDefinition,
pointCount: 1,
distances: Float32Array.of(0),
sampleRate: 8000,
fftSize: 256,
speedOfSound: 343,
compensateFrequencyResponse: true,
pointBatchSize: 64,
};
const limited = await computeBinMap(options);
const noBoost = await computeBinMap({ ...options, maxCompensationDb: 0 });
const explicitLegacyLimit = await computeBinMap({ ...options, maxCompensationDb: 120 });
assertNear(limited[0], dbToLinear(24) ** 2, 1e-3, "default limited power");
assertNear(noBoost[0], 1, 1e-9, "zero boost limit");
assert(explicitLegacyLimit[0] > 1e11, "explicit 120 dB limit permits legacy boost");
});
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("여러 음향 경로를 하나의 frequency-domain transfer로 합성한다", () => {
const impulse = new Float32Array(16);
impulse[2] = 1;
const spectrum = prepareDelaySpectrum(impulse, 5);
const output = new Float32Array(16);
addFrequencyDomainPaths({
output,
spectrum,
paths: [
{ delaySamples: 3, gain: 1 },
{ delaySamples: 5, gain: 0.5 },
],
workspace: createDelayWorkspace(spectrum.fftSize),
});
assertNear(output[5], 1, 1e-6, "direct impulse");
assertNear(output[7], 0.5, 1e-6, "reflected impulse");
});
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("microphone synthesis가 shoebox sparse path를 실제 채널에 적용한다", () => {
const scene = {
sampleRate: 8000,
duration: 0.02,
speedOfSound: 343,
seed: 7,
mix: { normalization: "none", targetPeak: 0.95 },
acoustics: {
model: "shoebox",
room: { min: [-2, -2, -1], max: [2, 2, 3] },
reflections: { order: 1, wallGainDb: -12, scattering: 0 },
},
sources: [{
id: "tone", name: "tone", position: [0, 0, 1], gainDb: -30,
startTime: 0, duration: 0.02,
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
}],
ambient: [],
};
const result = synthesizeMicrophoneSignals({
scene,
arrayDefinition: { microphones: [{ id: "mic", position: [0, 0, 0] }] },
});
assertEqual(result.acousticModel, "shoebox", "reported acoustic model");
assertEqual(result.acousticPathCount, 7, "one direct and six image-source paths");
assertEqual(result.acousticPathCounts.reflection, 6, "reflection path count");
assert(result.preNormalizationPeak > 0, "multipath channel contains signal");
});
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("합성 microphone channel에 주파수별 응답 gain을 적용한다", () => {
const scene = {
sampleRate: 8000,
duration: 0.256,
speedOfSound: 343,
seed: 1,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [
{
id: "lower", name: "lower", position: [0, 0, 0], gainDb: -30, startTime: 0, duration: 0.256,
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
},
{
id: "upper", name: "upper", position: [0, 0, 0], gainDb: -30, startTime: 0, duration: 0.256,
signal: { type: "tone", frequency: 1000, phaseDeg: 0 },
},
],
ambient: [],
};
const baselineArray = { microphones: [{ id: "mic", position: [0, 0, 0] }] };
const responseArray = {
microphones: [{
id: "mic",
position: [0, 0, 0],
frequencyResponse: [
{ frequencyHz: 500, gainDb: -6.020599913 },
{ frequencyHz: 1000, gainDb: 6.020599913 },
],
}],
};
const baseline = fftReal(synthesizeMicrophoneSignals({ scene, arrayDefinition: baselineArray }).channels[0]);
const filtered = fftReal(synthesizeMicrophoneSignals({ scene, arrayDefinition: responseArray }).channels[0]);
const magnitudeAt = (spectrum, frequency) => {
const bin = Math.round((frequency * spectrum.real.length) / scene.sampleRate);
return Math.hypot(spectrum.real[bin], spectrum.imaginary[bin]);
};
assertNear(
magnitudeAt(filtered, 500) / magnitudeAt(baseline, 500),
0.5,
0.015,
"500 Hz channel response",
);
assertNear(
magnitudeAt(filtered, 1000) / magnitudeAt(baseline, 1000),
2,
0.03,
"1 kHz channel response",
);
});
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;
microphone.frequencyResponse = [
{ frequencyHz: 0, gainDb: (index % 4) * 6 - 9, phaseDeg: (index % 8) * 30 - 105 },
{ frequencyHz: 4000, gainDb: (index % 4) * 6 - 9, phaseDeg: (index % 8) * 30 - 105 },
];
});
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");
const uncompensated = await computeDasMap({
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
scene,
experiment: {
...experiment,
beamforming: { ...experiment.beamforming, compensateFrequencyResponse: false },
},
});
assert(
result.bands[0].peak.rawPower > uncompensated.bands[0].peak.rawPower * 1.5,
"known response compensation improves coherent peak power",
);
});
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]}`);
const legacyMap = await computeBinMap({
bin,
spectrum,
frameCount: 1,
arrayDefinition,
pointCount: 2,
distances: createDistanceMatrix(arrayDefinition, scan),
sampleRate,
fftSize,
speedOfSound,
pointBatchSize: 64,
compensateFrequencyResponse: false,
});
assertEqual(legacyMap[0], map[0], "response-free map remains unchanged when compensation is disabled");
assertEqual(legacyMap[1], map[1], "response-free target power remains unchanged");
});
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");
});
test("scene mix가 peak 정규화와 clipping 통계를 정확히 기록한다", () => {
const assetBuffers = new Map([
["levels", { sampleRate: 4, channels: [Float32Array.of(2, -1.5, 1, -0.5)] }],
]);
const scene = {
sampleRate: 4,
duration: 1,
seed: 1,
mix: { normalization: "peak", targetPeak: 0.5 },
sources: [{
id: "levels",
gainDb: 0,
startTime: 0,
duration: 1,
signal: { type: "asset", assetId: "levels" },
}],
ambient: [],
};
const normalized = renderSceneMix(scene, assetBuffers);
assertNear(normalized.preNormalizationPeak, 2, 1e-12, "pre-normalization peak");
assertNear(normalized.appliedScale, 0.25, 1e-12, "normalization scale");
assertNear(normalized.peak, 0.5, 1e-12, "normalized peak");
assertEqual(normalized.clippedSampleCount, 0, "normalized clipping count");
[0.5, -0.375, 0.25, -0.125].forEach((expected, index) => {
assertNear(normalized.samples[index], expected, 1e-12, `normalized sample ${index}`);
});
const unnormalized = renderSceneMix({ ...scene, mix: { normalization: "none", targetPeak: 0.5 } }, assetBuffers);
assertNear(unnormalized.preNormalizationPeak, 2, 1e-12, "unnormalized input peak");
assertNear(unnormalized.appliedScale, 1, 1e-12, "unnormalized scale");
assertNear(unnormalized.peak, 2, 1e-12, "unnormalized output peak");
assertEqual(unnormalized.clippedSampleCount, 2, "unnormalized clipping count");
});
test("PCM16 WAV가 표준 mono header를 쓰고 범위 밖 sample을 clamp한다", async () => {
const blob = encodeMonoPcm16Wav(Float32Array.of(-2, -1, -0.5, 0, 0.5, 1, 2), 8000);
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
const ascii = (offset, length) => String.fromCharCode(...bytes.subarray(offset, offset + length));
assertEqual(blob.type, "audio/wav", "WAV MIME type");
assertEqual(buffer.byteLength, 58, "WAV byte length");
assertEqual(ascii(0, 4), "RIFF", "RIFF id");
assertEqual(view.getUint32(4, true), 50, "RIFF chunk size");
assertEqual(ascii(8, 4), "WAVE", "WAVE id");
assertEqual(ascii(12, 4), "fmt ", "fmt id");
assertEqual(view.getUint32(16, true), 16, "PCM fmt chunk size");
assertEqual(view.getUint16(20, true), 1, "PCM format");
assertEqual(view.getUint16(22, true), 1, "mono channel count");
assertEqual(view.getUint32(24, true), 8000, "sample rate");
assertEqual(view.getUint32(28, true), 16000, "byte rate");
assertEqual(view.getUint16(32, true), 2, "block align");
assertEqual(view.getUint16(34, true), 16, "bits per sample");
assertEqual(ascii(36, 4), "data", "data id");
assertEqual(view.getUint32(40, true), 14, "PCM data byte length");
const actualSamples = Array.from({ length: 7 }, (_, index) => view.getInt16(44 + index * 2, true));
const expectedSamples = [-32768, -32768, -16384, 0, 16383, 32767, 32767];
expectedSamples.forEach((expected, index) => {
assertEqual(actualSamples[index], expected, `PCM sample ${index}`);
});
});
test("quasi-static synthesis window aligns samples, clips activity, and samples midpoint positions", () => {
const scene = {
sampleRate: 10,
duration: 1,
sources: [
{ id: "ended", position: [0, 0, 1], startTime: 0, duration: 0.2, signal: { type: "tone", frequency: 1 } },
{
id: "active", position: [0, 0, 1], startTime: 0.15, duration: 0.5,
trajectory: [
{ time: 0, position: [0, 0, 1] },
{ time: 1, position: [1, 2, 3] },
],
signal: { type: "tone", frequency: 1 },
},
{ id: "starts-inside", position: [0, 0, 1], startTime: 0.45, duration: 0.2, signal: { type: "tone", frequency: 1 } },
{ id: "later", position: [0, 0, 1], startTime: 0.8, duration: 0.1, signal: { type: "tone", frequency: 1 } },
],
ambient: [{ id: "room", signal: { type: "noise", color: "white" } }],
};
const window = sampleQuasiStaticWindow(scene, 0.21, 0.69);
assertEqual(window.startSample, 3, "aligned start sample");
assertEqual(window.endSample, 7, "aligned end sample");
assertEqual(window.sampleCount, 4, "window sample count");
assertNear(window.scene.duration, 0.4, 1e-12, "short scene duration");
assertEqual(window.scene.sources.map(({ id }) => id).join(","), "active,starts-inside", "active sources");
assertNear(window.scene.sources[0].position[0], 0.5, 1e-12, "midpoint x");
assertNear(window.scene.sources[0].position[1], 1, 1e-12, "midpoint y");
assertNear(window.scene.sources[0].startTime, 0, 1e-12, "active local start");
assertNear(window.scene.sources[0].duration, 0.4, 1e-12, "active clipped duration");
assertNear(window.scene.sources[1].startTime, 0.2, 1e-12, "inside local start");
assertNear(window.scene.sources[1].duration, 0.2, 1e-12, "inside clipped duration");
assertEqual(window.signalOffsets.sceneSampleOffset, 3, "absolute output origin");
assertNear(window.signalOffsets.sources[0].signalStartTime, 0.15, 1e-12, "signal time origin");
assertEqual(window.signalOffsets.sources[0].signalStartSample, 2, "signal sample origin");
assertEqual(window.signalOffsets.ambient.length, 1, "ambient offset count");
assertEqual(scene.sources.length, 4, "input scene is unchanged");
});
test("absolute scene windows preserve tone, noise, asset, ambient, and synthesis continuity", () => {
const scene = {
sampleRate: 16,
duration: 1,
speedOfSound: 343,
seed: 23,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [
{ id: "tone", position: [0, 0, 0], gainDb: -3, startTime: 1 / 16, duration: 14 / 16, signal: { type: "tone", frequency: 3, phaseDeg: 27 } },
{ id: "noise", position: [0, 0, 0], gainDb: -6, startTime: 2 / 16, duration: 13 / 16, signal: { type: "noise", color: "pink", seedOffset: 9 } },
{ id: "asset-source", position: [0, 0, 0], gainDb: 0, startTime: 3 / 16, duration: 12 / 16, signal: { type: "asset", assetId: "clip", channel: 0, loop: true } },
],
ambient: [{ id: "ambient", gainDb: -9, signal: { type: "noise", color: "white", seedOffset: 4 } }],
};
const assetBuffers = new Map([["clip", {
sampleRate: 16,
channels: [Float32Array.from({ length: 7 }, (_, index) => (index - 3) / 4)],
}]]);
const window = sampleQuasiStaticWindow(scene, 4 / 16, 12 / 16);
scene.sources.forEach((source, index) => {
const whole = renderSignal(source, scene, assetBuffers);
const partial = renderSignal(window.scene.sources[index], window.scene, assetBuffers, 0, {
sceneSampleOffset: window.signalOffsets.sceneSampleOffset,
...window.signalOffsets.sources[index],
});
partial.forEach((value, sampleIndex) => {
assertEqual(value, whole[window.startSample + sampleIndex], `${source.id} sample ${sampleIndex}`);
});
});
const wholeAmbient = renderSignal(scene.ambient[0], scene, assetBuffers, 77);
const partialAmbient = renderSignal(window.scene.ambient[0], window.scene, assetBuffers, 77, {
sceneSampleOffset: window.signalOffsets.sceneSampleOffset,
...window.signalOffsets.ambient[0],
});
partialAmbient.forEach((value, sampleIndex) => {
assertEqual(value, wholeAmbient[window.startSample + sampleIndex], `ambient sample ${sampleIndex}`);
});
const arrayDefinition = { microphones: [{ id: "mic", position: [0, 0, 0] }] };
const wholeSynthesis = synthesizeMicrophoneSignals({ scene, arrayDefinition, assetBuffers, referenceDistance: 1, minimumDistance: 1 });
const partialSynthesis = synthesizeMicrophoneSignals({
scene: window.scene,
arrayDefinition,
assetBuffers,
signalOffsets: window.signalOffsets,
referenceDistance: 1,
minimumDistance: 1,
});
assertEqual(partialSynthesis.channels[0].length, window.sampleCount, "short synthesis length");
partialSynthesis.channels[0].forEach((value, sampleIndex) => {
assertNear(value, wholeSynthesis.channels[0][window.startSample + sampleIndex], 2e-5, `mic sample ${sampleIndex}`);
});
});