2053 lines
71 KiB
JavaScript
2053 lines
71 KiB
JavaScript
import { computeBinMap, computeDasMap, createDistanceMatrix } from "/packages/acoustics-core/das.js";
|
|
import { fftInPlace, fftReal } from "/packages/acoustics-core/fft.js";
|
|
import {
|
|
buildAcousticPaths,
|
|
maximumAcousticDelaySeconds,
|
|
selectAcousticPathSummary,
|
|
summarizeAcousticPaths,
|
|
} 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 {
|
|
ceilSampleIndex,
|
|
ceilSamplePosition,
|
|
dbToLinear,
|
|
distance3,
|
|
} from "/packages/acoustics-core/math.js";
|
|
import {
|
|
DEFAULT_LOCALIZATION_ALGORITHM,
|
|
LOCALIZATION_ALGORITHM_IDS,
|
|
isDasAlgorithm,
|
|
localizationAlgorithmDefinition,
|
|
localizationAlgorithmLabel,
|
|
} from "/packages/acoustics-core/localization-algorithms.js";
|
|
import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
|
|
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
|
|
import {
|
|
estimateSynthesisWorkingSet,
|
|
sphericalAttenuation,
|
|
synthesizeMicrophoneSignals,
|
|
} from "/packages/acoustics-core/propagation.js";
|
|
import { renderSignal } from "/packages/acoustics-core/signal.js";
|
|
import {
|
|
describeFrequencyAliasing,
|
|
foldFrequencyToNyquist,
|
|
observeFrequencyBand,
|
|
} from "/packages/acoustics-core/sampling.js";
|
|
import { binsForBand, 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}`);
|
|
}
|
|
|
|
test("localization algorithm registry exposes stable ids and capabilities", () => {
|
|
assertEqual(DEFAULT_LOCALIZATION_ALGORITHM, "das", "default algorithm");
|
|
assertEqual(
|
|
LOCALIZATION_ALGORITHM_IDS.join(","),
|
|
"das,das-phat,gcc-phat,music",
|
|
"stable algorithm ids",
|
|
);
|
|
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS map scale");
|
|
assertEqual(
|
|
localizationAlgorithmDefinition("das-phat").spectralWeighting,
|
|
"phat",
|
|
"DAS-PHAT weighting",
|
|
);
|
|
assertEqual(
|
|
localizationAlgorithmDefinition("gcc-phat").backends.join(","),
|
|
"cpu",
|
|
"GCC-PHAT backend capability",
|
|
);
|
|
assertEqual(
|
|
localizationAlgorithmDefinition("music").rawScale,
|
|
"noise-projection",
|
|
"MUSIC map scale",
|
|
);
|
|
assert(isDasAlgorithm("das") && isDasAlgorithm("das-phat"), "DAS engine algorithms");
|
|
assert(!isDasAlgorithm("gcc-phat"), "GCC-PHAT uses a separate engine");
|
|
assert(!isDasAlgorithm("music"), "MUSIC uses a separate engine");
|
|
assertEqual(localizationAlgorithmLabel("das-phat"), "DAS-PHAT", "algorithm label");
|
|
assertEqual(localizationAlgorithmDefinition("unknown"), undefined, "unknown lookup");
|
|
});
|
|
|
|
test("CPU DAS-PHAT is invariant to spectrum and calibration amplitude gains", async () => {
|
|
const sampleRate = 8_000;
|
|
const fftSize = 256;
|
|
const bin = 32;
|
|
const frequency = (bin * sampleRate) / fftSize;
|
|
const speedOfSound = 343;
|
|
const target = [0.2, 0, 1];
|
|
const positions = [[-0.15, 0, 0], [-0.02, 0, 0], [0.12, 0, 0]];
|
|
const phaseOffsets = [30, -45, 75];
|
|
const responsePhases = [10, 20, -35];
|
|
const createDefinition = (withAmplitudeMetadata) => ({
|
|
microphones: positions.map((position, index) => ({
|
|
id: `mic-${index}`,
|
|
position,
|
|
sensitivityDb: withAmplitudeMetadata ? [-30, 24, 60][index] : 0,
|
|
phaseOffsetDeg: phaseOffsets[index],
|
|
frequencyResponse: [
|
|
{
|
|
frequencyHz: 0,
|
|
gainDb: withAmplitudeMetadata ? [-60, 40, 100][index] : 0,
|
|
phaseDeg: responsePhases[index],
|
|
},
|
|
{
|
|
frequencyHz: 4_000,
|
|
gainDb: withAmplitudeMetadata ? [-60, 40, 100][index] : 0,
|
|
phaseDeg: responsePhases[index],
|
|
},
|
|
],
|
|
})),
|
|
});
|
|
const createSpectrum = (magnitudes) => {
|
|
const real = new Float32Array(positions.length);
|
|
const imaginary = new Float32Array(positions.length);
|
|
positions.forEach((position, index) => {
|
|
const propagationPhase =
|
|
(2 * Math.PI * frequency * distance3(target, position)) / speedOfSound;
|
|
const calibrationPhase =
|
|
((phaseOffsets[index] + responsePhases[index]) * Math.PI) / 180;
|
|
const observedPhase = -propagationPhase + calibrationPhase;
|
|
real[index] = Math.cos(observedPhase) * magnitudes[index];
|
|
imaginary[index] = Math.sin(observedPhase) * magnitudes[index];
|
|
});
|
|
return { real, imaginary };
|
|
};
|
|
const scan = {
|
|
z: 1,
|
|
xMin: -0.2,
|
|
xMax: 0.2,
|
|
yMin: 0,
|
|
yMax: 0,
|
|
columns: 2,
|
|
rows: 1,
|
|
};
|
|
const baselineArray = createDefinition(false);
|
|
const gainedArray = createDefinition(true);
|
|
const common = {
|
|
bin,
|
|
frameCount: 1,
|
|
pointCount: 2,
|
|
sampleRate,
|
|
fftSize,
|
|
speedOfSound,
|
|
algorithm: "das-phat",
|
|
pointBatchSize: 64,
|
|
};
|
|
const baseline = await computeBinMap({
|
|
...common,
|
|
spectrum: createSpectrum([1, 1, 1]),
|
|
arrayDefinition: baselineArray,
|
|
distances: createDistanceMatrix(baselineArray, scan),
|
|
});
|
|
const gained = await computeBinMap({
|
|
...common,
|
|
spectrum: createSpectrum([0.125, 8, 32]),
|
|
arrayDefinition: gainedArray,
|
|
distances: createDistanceMatrix(gainedArray, scan),
|
|
});
|
|
|
|
baseline.forEach((value, index) => {
|
|
assertNear(gained[index], value, 2e-6, `gain-invariant map value ${index}`);
|
|
});
|
|
assertNear(baseline[1], 1, 2e-6, "phase-compensated target score");
|
|
assert(baseline[1] > baseline[0] * 1.1, "phase compensation preserves target peak");
|
|
});
|
|
|
|
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("sample boundary ceil ignores only IEEE-754 integer roundoff", () => {
|
|
assertEqual(ceilSampleIndex(0.1 + 0.2, 1_000), 300, "summed exact boundary");
|
|
assertEqual(ceilSamplePosition(300 + 1e-10), 301, "physical sub-sample offset is not snapped");
|
|
assertEqual(ceilSampleIndex(0.300_001, 1_000), 301, "physical time offset is ceiled");
|
|
});
|
|
|
|
test("ideal ADC sampling reports signed aliases without hiding Nyquist folding", () => {
|
|
const first = describeFrequencyAliasing(5_000, 8_000);
|
|
assertEqual(first.adcFrequencyHz, 3_000, "first folded frequency");
|
|
assertEqual(first.signedAdcFrequencyHz, -3_000, "first folded signed frequency");
|
|
assert(first.aliased && first.spectralInversion, "first fold metadata");
|
|
assertEqual(foldFrequencyToNyquist(13_000, 8_000), 3_000, "multiple fold");
|
|
assertEqual(foldFrequencyToNyquist(16_000, 8_000), 0, "sample-rate multiple folds to DC");
|
|
const dc = describeFrequencyAliasing(8_000, 8_000);
|
|
assert(dc.dcEdge && dc.phaseDegenerate, "sample-rate multiple is a phase-degenerate DC fold");
|
|
const edge = describeFrequencyAliasing(4_000, 8_000);
|
|
assert(
|
|
edge.nyquistEdge && edge.phaseDegenerate && !edge.aliased,
|
|
"exact Nyquist is retained as a phase-degenerate edge",
|
|
);
|
|
});
|
|
|
|
test("above-Nyquist requested bands retain every physically observable ADC bin", () => {
|
|
const observation = observeFrequencyBand(
|
|
{ minHz: 4_900, maxHz: 5_100 },
|
|
8_000,
|
|
256,
|
|
);
|
|
assert(observation.aliased, "band is marked aliased");
|
|
assertEqual(observation.bins[0], 93, "folded lower ADC bin");
|
|
assertEqual(observation.bins.at(-1), 99, "folded upper ADC bin");
|
|
assertEqual(
|
|
JSON.stringify(binsForBand({ minHz: 4_900, maxHz: 5_100 }, 8_000, 256)),
|
|
JSON.stringify(observation.bins),
|
|
"STFT uses the explicit observation mapping",
|
|
);
|
|
|
|
const wide = observeFrequencyBand({ minHz: 0, maxHz: 8_000 }, 8_000, 256);
|
|
assertEqual(wide.bins.length, 129, "one sample-rate interval covers every real FFT bin");
|
|
assert(wide.continuousManyToOne, "a full sample-rate interval folds continuously many-to-one");
|
|
assert(wide.gridManyToOne, "multiple analog FFT-grid bins share ADC bins");
|
|
assertEqual(wide.foldSegments.length, 2, "first and second Nyquist zones remain explicit");
|
|
assertEqual(wide.foldSegments[0].nyquistZone, 1, "Nyquist zones are one-based");
|
|
assertEqual(wide.foldSegments[1].nyquistZone, 2, "second Nyquist zone");
|
|
assert(!wide.foldSegments[0].inverted, "first Nyquist zone is not inverted");
|
|
assert(wide.foldSegments[1].inverted, "second Nyquist zone is inverted");
|
|
for (const segment of wide.foldSegments) {
|
|
assertNear(
|
|
segment.signedAdcStartHz + segment.aliasOrder * wide.sampleRate,
|
|
segment.analogMinHz,
|
|
1e-12,
|
|
`zone ${segment.nyquistZone} start invariant`,
|
|
);
|
|
assertNear(
|
|
segment.signedAdcEndHz + segment.aliasOrder * wide.sampleRate,
|
|
segment.analogMaxHz,
|
|
1e-12,
|
|
`zone ${segment.nyquistZone} end invariant`,
|
|
);
|
|
}
|
|
|
|
const baseband = observeFrequencyBand({ minHz: 2_900, maxHz: 3_100 }, 8_000, 256);
|
|
const equivalentAlias = observeFrequencyBand(
|
|
{ minHz: 4_900, maxHz: 5_100 },
|
|
8_000,
|
|
256,
|
|
);
|
|
assertEqual(
|
|
equivalentAlias.adcBinSetKey,
|
|
baseband.adcBinSetKey,
|
|
"alias-equivalent requested bands disclose the same ADC bin set",
|
|
);
|
|
|
|
const narrowFold = observeFrequencyBand(
|
|
{ minHz: 3_999, maxHz: 4_001 },
|
|
8_000,
|
|
256,
|
|
);
|
|
assert(
|
|
narrowFold.continuousManyToOne,
|
|
"a band straddling Nyquist is continuously many-to-one even on a fine FFT grid",
|
|
);
|
|
assert(!narrowFold.gridManyToOne, "the single selected bin is not grid many-to-one");
|
|
assertEqual(narrowFold.bins.join(","), "128", "only the Nyquist bin center is selected");
|
|
|
|
let segmentLimitError;
|
|
try {
|
|
observeFrequencyBand({ minHz: 0, maxHz: 1_000_000 }, 1, 256);
|
|
} catch (error) {
|
|
segmentLimitError = error;
|
|
}
|
|
assert(
|
|
segmentLimitError?.message.includes("explicit limit"),
|
|
"portable helper rejects excessive fold-zone enumeration before looping",
|
|
);
|
|
});
|
|
|
|
test("real ADC samples and FFT expose temporal aliases without carrier recovery", () => {
|
|
const sampleRate = 8_000;
|
|
const fftSize = 256;
|
|
const phaseDeg = 23;
|
|
const scene = { sampleRate, duration: fftSize / sampleRate, seed: 1 };
|
|
const entry = {
|
|
id: "temporal-alias",
|
|
startTime: 0,
|
|
duration: scene.duration,
|
|
gainDb: 0,
|
|
signal: { type: "tone", frequency: 5_000, phaseDeg },
|
|
};
|
|
const first = renderSignal(entry, scene);
|
|
const second = renderSignal(
|
|
{ ...entry, signal: { ...entry.signal, frequency: 13_000 } },
|
|
scene,
|
|
);
|
|
const phase = (phaseDeg * Math.PI) / 180;
|
|
for (let index = 0; index < 32; index += 1) {
|
|
const expected = Math.sin(
|
|
(2 * Math.PI * -3_000 * index) / sampleRate + phase,
|
|
);
|
|
assertNear(first[index], expected, 2e-6, `5 kHz alias sample ${index}`);
|
|
assertNear(second[index], expected, 4e-6, `13 kHz alias sample ${index}`);
|
|
}
|
|
const spectrum = fftReal(first);
|
|
let peakBin = 0;
|
|
let peakPower = -Infinity;
|
|
for (let bin = 0; bin <= fftSize / 2; bin += 1) {
|
|
const power = spectrum.real[bin] ** 2 + spectrum.imaginary[bin] ** 2;
|
|
if (power > peakPower) {
|
|
peakPower = power;
|
|
peakBin = bin;
|
|
}
|
|
}
|
|
assertEqual(peakBin, 96, "5 kHz analog tone appears at the 3 kHz ADC bin");
|
|
});
|
|
|
|
test("undersampled microphone spacing preserves a physical grating-lobe ambiguity", async () => {
|
|
const sampleRate = 8_000;
|
|
const fftSize = 256;
|
|
const frequencyHz = 1_000;
|
|
const bin = 32;
|
|
const speedOfSound = 343;
|
|
const wavelength = speedOfSound / frequencyHz;
|
|
const microphoneSpacing = 0.5;
|
|
assert(
|
|
microphoneSpacing > wavelength / 2,
|
|
"fixture is spatially undersampled",
|
|
);
|
|
const arrayDefinition = {
|
|
microphones: [
|
|
{ id: "left", position: [-microphoneSpacing / 2, 0, 0] },
|
|
{ id: "right", position: [microphoneSpacing / 2, 0, 0] },
|
|
],
|
|
};
|
|
const targetX = -1;
|
|
const z = 0.5;
|
|
const pathDifference = (x) =>
|
|
distance3([x, 0, z], arrayDefinition.microphones[1].position) -
|
|
distance3([x, 0, z], arrayDefinition.microphones[0].position);
|
|
const desiredDifference = pathDifference(targetX) - wavelength;
|
|
let lowerX = targetX;
|
|
let upperX = 0;
|
|
for (let iteration = 0; iteration < 80; iteration += 1) {
|
|
const middleX = (lowerX + upperX) / 2;
|
|
if (pathDifference(middleX) > desiredDifference) lowerX = middleX;
|
|
else upperX = middleX;
|
|
}
|
|
const ambiguousX = (lowerX + upperX) / 2;
|
|
assertNear(
|
|
pathDifference(targetX) - pathDifference(ambiguousX),
|
|
wavelength,
|
|
1e-12,
|
|
"candidate path-difference offset is one wavelength",
|
|
);
|
|
|
|
const angularWaveNumber =
|
|
(2 * Math.PI * frequencyHz) / speedOfSound;
|
|
const spectrum = {
|
|
real: new Float32Array(2),
|
|
imaginary: new Float32Array(2),
|
|
};
|
|
arrayDefinition.microphones.forEach((microphone, index) => {
|
|
const phase = -angularWaveNumber * distance3([targetX, 0, z], microphone.position);
|
|
spectrum.real[index] = Math.cos(phase);
|
|
spectrum.imaginary[index] = Math.sin(phase);
|
|
});
|
|
const scan = {
|
|
xMin: targetX,
|
|
xMax: ambiguousX,
|
|
yMin: 0,
|
|
yMax: 0,
|
|
z,
|
|
columns: 2,
|
|
rows: 1,
|
|
};
|
|
const map = await computeBinMap({
|
|
bin,
|
|
spectrum,
|
|
frameCount: 1,
|
|
arrayDefinition,
|
|
pointCount: 2,
|
|
distances: createDistanceMatrix(arrayDefinition, scan),
|
|
sampleRate,
|
|
fftSize,
|
|
speedOfSound,
|
|
compensateFrequencyResponse: false,
|
|
algorithm: "das",
|
|
});
|
|
assertNear(map[0], map[1], 2e-6, "true point and grating lobe have equal DAS power");
|
|
assert(map[0] > 0.999, "both ambiguous candidates remain coherent");
|
|
});
|
|
|
|
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("above-Nyquist tone keeps analog propagation phase before ADC aliasing", () => {
|
|
const sampleRate = 8_000;
|
|
const analogFrequencyHz = 5_000;
|
|
const speedOfSound = 343;
|
|
const differentialDelay = 0.00005;
|
|
const scene = {
|
|
sampleRate,
|
|
duration: 0.01,
|
|
speedOfSound,
|
|
seed: 1,
|
|
mix: { normalization: "none", targetPeak: 0.95 },
|
|
sources: [{
|
|
id: "aliased-tone",
|
|
name: "aliased tone",
|
|
position: [0, 0, 0],
|
|
gainDb: 0,
|
|
startTime: 0,
|
|
duration: 0.01,
|
|
signal: { type: "tone", frequency: analogFrequencyHz, phaseDeg: 0 },
|
|
}],
|
|
ambient: [],
|
|
};
|
|
const arrayDefinition = {
|
|
microphones: [
|
|
{ id: "reference", position: [0, 0, 0] },
|
|
{ id: "delayed", position: [differentialDelay * speedOfSound, 0, 0] },
|
|
],
|
|
};
|
|
const synthesis = synthesizeMicrophoneSignals({
|
|
scene,
|
|
arrayDefinition,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
});
|
|
const sampleIndex = 20;
|
|
const sampleTime = sampleIndex / sampleRate;
|
|
assertNear(
|
|
synthesis.channels[0][sampleIndex],
|
|
Math.sin(2 * Math.PI * analogFrequencyHz * sampleTime),
|
|
1e-6,
|
|
"reference temporal alias sample",
|
|
);
|
|
assertNear(
|
|
synthesis.channels[1][sampleIndex],
|
|
Math.sin(2 * Math.PI * analogFrequencyHz * (sampleTime - differentialDelay)),
|
|
1e-6,
|
|
"delayed sensor uses original analog carrier phase",
|
|
);
|
|
const wrongAliasPhase = Math.sin(
|
|
2 * Math.PI * 3_000 * (sampleTime - differentialDelay),
|
|
);
|
|
assert(
|
|
Math.abs(synthesis.channels[1][sampleIndex] - wrongAliasPhase) > 0.1,
|
|
"fractional spatial delay is not rewritten using the positive alias carrier",
|
|
);
|
|
assertEqual(
|
|
synthesis.sampling.toneObservations[0].adcFrequencyHz,
|
|
3_000,
|
|
"synthesis exposes the recorded ADC alias",
|
|
);
|
|
});
|
|
|
|
test("above-Nyquist tone applies microphone response at the analog carrier", () => {
|
|
const scene = {
|
|
sampleRate: 8_000,
|
|
duration: 0.032,
|
|
speedOfSound: 343,
|
|
seed: 1,
|
|
mix: { normalization: "none", targetPeak: 0.95 },
|
|
sources: [{
|
|
id: "response-tone",
|
|
name: "response tone",
|
|
position: [0, 0, 0],
|
|
gainDb: -20,
|
|
startTime: 0,
|
|
duration: 0.032,
|
|
signal: { type: "tone", frequency: 5_000, phaseDeg: 20 },
|
|
}],
|
|
ambient: [],
|
|
};
|
|
const baselineArray = {
|
|
microphones: [{ id: "mic", position: [0, 0, 0] }],
|
|
};
|
|
const responseArray = {
|
|
microphones: [{
|
|
id: "mic",
|
|
position: [0, 0, 0],
|
|
frequencyResponse: [
|
|
{ frequencyHz: 3_000, gainDb: -6.020599913, phaseDeg: -30 },
|
|
{ frequencyHz: 5_000, gainDb: 6.020599913, phaseDeg: 45 },
|
|
],
|
|
}],
|
|
};
|
|
const baseline = synthesizeMicrophoneSignals({
|
|
scene,
|
|
arrayDefinition: baselineArray,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
}).channels[0];
|
|
const filtered = synthesizeMicrophoneSignals({
|
|
scene,
|
|
arrayDefinition: responseArray,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
}).channels[0];
|
|
assertNear(
|
|
rms(filtered, 16) / rms(baseline, 16),
|
|
2,
|
|
2e-5,
|
|
"5 kHz response gain is used instead of the 3 kHz ADC alias response",
|
|
);
|
|
});
|
|
|
|
test("analytic sampler preserves exact aliased DC and Nyquist phase degeneracy", () => {
|
|
const renderTone = (frequency, phaseDeg) => {
|
|
const scene = {
|
|
sampleRate: 8_000,
|
|
duration: 8 / 8_000,
|
|
speedOfSound: 343,
|
|
seed: 1,
|
|
mix: { normalization: "none", targetPeak: 0.95 },
|
|
sources: [{
|
|
id: `edge-${frequency}-${phaseDeg}`,
|
|
name: "edge tone",
|
|
position: [0, 0, 0],
|
|
gainDb: 0,
|
|
startTime: 0,
|
|
duration: 8 / 8_000,
|
|
signal: { type: "tone", frequency, phaseDeg },
|
|
}],
|
|
ambient: [],
|
|
};
|
|
return synthesizeMicrophoneSignals({
|
|
scene,
|
|
arrayDefinition: {
|
|
microphones: [{ id: "reference", position: [0, 0, 0] }],
|
|
},
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
});
|
|
};
|
|
|
|
const vanishedDc = renderTone(8_000, 0);
|
|
for (const [sampleIndex, value] of vanishedDc.channels[0].entries()) {
|
|
assertNear(value, 0, 1e-10, `aliased DC phase-zero sample ${sampleIndex}`);
|
|
}
|
|
assert(
|
|
vanishedDc.sampling.toneObservations[0].dcEdge &&
|
|
vanishedDc.sampling.toneObservations[0].phaseDegenerate,
|
|
"sample-rate carrier is explicitly a phase-degenerate DC alias",
|
|
);
|
|
|
|
const constantDc = renderTone(8_000, 90);
|
|
for (const [sampleIndex, value] of constantDc.channels[0].entries()) {
|
|
assertNear(value, 1, 1e-6, `aliased DC quadrature sample ${sampleIndex}`);
|
|
}
|
|
|
|
const nyquist = renderTone(12_000, 90);
|
|
for (const [sampleIndex, value] of nyquist.channels[0].entries()) {
|
|
assertNear(
|
|
value,
|
|
sampleIndex % 2 === 0 ? 1 : -1,
|
|
1e-6,
|
|
`aliased Nyquist alternating sample ${sampleIndex}`,
|
|
);
|
|
}
|
|
assert(
|
|
nyquist.sampling.toneObservations[0].nyquistEdge &&
|
|
nyquist.sampling.toneObservations[0].phaseDegenerate,
|
|
"odd Nyquist-zone edge remains explicitly phase-degenerate",
|
|
);
|
|
});
|
|
|
|
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;
|
|
assertEqual(result.algorithm, "das", "default result algorithm");
|
|
assertEqual(result.bands[0].rawScale, "power", "DAS raw map scale");
|
|
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 phaseOnly = await computeDasMap({
|
|
channels: synthesis.channels,
|
|
sampleRate: synthesis.sampleRate,
|
|
arrayDefinition,
|
|
scene,
|
|
experiment: {
|
|
...experiment,
|
|
beamforming: { ...experiment.beamforming, algorithm: "das-phat" },
|
|
},
|
|
});
|
|
assertEqual(phaseOnly.algorithm, "das-phat", "phase-only result algorithm");
|
|
assertEqual(phaseOnly.bands[0].rawScale, "score", "DAS-PHAT raw map scale");
|
|
assert(
|
|
distance3(phaseOnly.bands[0].peak.position, scene.sources[0].position) <= 0.071,
|
|
`DAS-PHAT peak=${phaseOnly.bands[0].peak.position.join(",")}`,
|
|
);
|
|
|
|
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}`);
|
|
});
|
|
});
|
|
|
|
test("quasi-static pre-roll preserves ended noise and asset arrivals before output crop", () => {
|
|
const sampleRate = 1_000;
|
|
const scene = {
|
|
sampleRate,
|
|
duration: 1,
|
|
speedOfSound: 100,
|
|
seed: 73,
|
|
mix: { normalization: "none", targetPeak: 0.95 },
|
|
sources: [
|
|
{
|
|
id: "ended-noise",
|
|
position: [20, 0, 0],
|
|
gainDb: -12,
|
|
startTime: 0.1,
|
|
duration: 0.2,
|
|
signal: { type: "noise", color: "white", seedOffset: 11 },
|
|
},
|
|
{
|
|
id: "ended-asset",
|
|
position: [20, 0, 0],
|
|
gainDb: -9,
|
|
startTime: 0.1,
|
|
duration: 0.2,
|
|
signal: { type: "asset", assetId: "tail-clip", channel: 0, loop: false },
|
|
},
|
|
],
|
|
ambient: [],
|
|
};
|
|
const arrayDefinition = {
|
|
microphones: [{ id: "mic", position: [0, 0, 0] }],
|
|
};
|
|
const assetBuffers = new Map([["tail-clip", {
|
|
sampleRate,
|
|
channels: [Float32Array.from(
|
|
{ length: 300 },
|
|
(_, index) => 0.45 * Math.sin((2 * Math.PI * index) / 29) + (index % 17) / 80,
|
|
)],
|
|
}]]);
|
|
const whole = synthesizeMicrophoneSignals({
|
|
scene,
|
|
arrayDefinition,
|
|
assetBuffers,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
});
|
|
const window = sampleQuasiStaticWindow(scene, 0.4, 0.5, {
|
|
arrayDefinition,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
});
|
|
assertEqual(window.preRollSamples, 200, "direct-path pre-roll");
|
|
assertEqual(
|
|
window.scene.sources.map(({ id }) => id).join(","),
|
|
"ended-noise,ended-asset",
|
|
"sources that stopped before the visible window remain in the render interval",
|
|
);
|
|
const partial = synthesizeMicrophoneSignals({
|
|
scene: window.scene,
|
|
arrayDefinition,
|
|
assetBuffers,
|
|
signalOffsets: window.signalOffsets,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
});
|
|
assertEqual(partial.channels[0].length, 100, "only the requested output window is returned");
|
|
assertEqual(partial.windowing.preRollSamples, 200, "synthesis records applied pre-roll");
|
|
assertEqual(
|
|
partial.windowing.delayedTailPolicy,
|
|
"propagation-preroll-then-output-crop",
|
|
"tail policy metadata",
|
|
);
|
|
assertEqual(
|
|
partial.windowing.arrivingSourceIds.join(","),
|
|
"ended-noise,ended-asset",
|
|
"arrival-overlap metadata includes both sampled sources",
|
|
);
|
|
for (const arrival of partial.windowing.sourceArrivals) {
|
|
assert(arrival.emissionEndSample < partial.windowing.outputStartSample, `${arrival.sourceId} ended before output`);
|
|
assert(arrival.overlapsOutput, `${arrival.sourceId} has a path arrival inside output`);
|
|
assertEqual(arrival.earliestArrivalSample, 300, `${arrival.sourceId} earliest direct arrival`);
|
|
assertEqual(arrival.latestArrivalEndSample, 500, `${arrival.sourceId} direct tail end`);
|
|
}
|
|
assert(rms(partial.channels[0]) > 1e-4, "the delayed sampled tail reaches the output window");
|
|
partial.channels[0].forEach((value, sampleIndex) => {
|
|
assertNear(
|
|
value,
|
|
whole.channels[0][window.startSample + sampleIndex],
|
|
3e-5,
|
|
`sampled tail ${sampleIndex}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("quasi-static pre-roll preserves reflection and diffuse tone tails", () => {
|
|
const sampleRate = 2_000;
|
|
const scene = {
|
|
sampleRate,
|
|
duration: 1,
|
|
speedOfSound: 100,
|
|
seed: 91,
|
|
mix: { normalization: "none", targetPeak: 0.95 },
|
|
acoustics: {
|
|
model: "shoebox",
|
|
room: { min: [0, 0, 0], max: [10, 10, 10] },
|
|
reflections: { order: 1, wallGainDb: -5, scattering: 0.3 },
|
|
diffuseReverb: { enabled: true, rt60Seconds: 0.4, gainDb: -8, tapCount: 4 },
|
|
occluders: [],
|
|
},
|
|
sources: [{
|
|
id: "ended-tone",
|
|
position: [2, 2, 2],
|
|
gainDb: -12,
|
|
startTime: 0.05,
|
|
duration: 0.1,
|
|
signal: { type: "tone", frequency: 137, phaseDeg: 31 },
|
|
}],
|
|
ambient: [],
|
|
};
|
|
const arrayDefinition = {
|
|
microphones: [{ id: "mic", position: [4, 2, 2] }],
|
|
};
|
|
const synthesisOptions = {
|
|
arrayDefinition,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
};
|
|
const whole = synthesizeMicrophoneSignals({ scene, ...synthesisOptions });
|
|
assert(whole.acousticPathCounts.reflection > 0, "fixture contains image-source reflections");
|
|
assert(whole.acousticPathCounts.diffuse > 0, "fixture contains diffuse tail taps");
|
|
|
|
for (const [startTime, endTime, label] of [
|
|
[0.22, 0.27, "reflection interval"],
|
|
[0.42, 0.48, "late diffuse interval"],
|
|
]) {
|
|
const window = sampleQuasiStaticWindow(scene, startTime, endTime, synthesisOptions);
|
|
assertEqual(window.scene.sources[0].id, "ended-tone", `${label} retains ended source`);
|
|
const partial = synthesizeMicrophoneSignals({
|
|
scene: window.scene,
|
|
signalOffsets: window.signalOffsets,
|
|
...synthesisOptions,
|
|
});
|
|
assertEqual(
|
|
partial.windowing.geometryModel,
|
|
"source-position-fixed-at-window-midpoint",
|
|
`${label} geometry metadata`,
|
|
);
|
|
assertEqual(partial.windowing.dopplerMode, "not-modeled", `${label} Doppler metadata`);
|
|
assertEqual(
|
|
partial.windowing.delayedTailPolicy,
|
|
"live-request-window-direct-output-with-analytic-path-gating",
|
|
`${label} direct live crop policy`,
|
|
);
|
|
assertEqual(
|
|
partial.windowing.arrivingSourceIds.join(","),
|
|
"ended-tone",
|
|
`${label} arrival truth source`,
|
|
);
|
|
assert(
|
|
partial.windowing.sourceArrivals[0].emissionEndSample <
|
|
partial.windowing.outputStartSample,
|
|
`${label} source emission ended before output`,
|
|
);
|
|
if (label === "late diffuse interval") {
|
|
assertEqual(
|
|
partial.windowing.sourceArrivals[0].overlappingPathKinds.join(","),
|
|
"diffuse",
|
|
"late interval truth is justified by a diffuse arrival",
|
|
);
|
|
} else {
|
|
assert(
|
|
partial.windowing.sourceArrivals[0].overlappingPathKinds.includes("reflection"),
|
|
"reflection interval truth includes an image-source arrival",
|
|
);
|
|
}
|
|
assert(rms(partial.channels[0]) > 1e-6, `${label} contains propagated energy`);
|
|
partial.channels[0].forEach((value, sampleIndex) => {
|
|
assertNear(
|
|
value,
|
|
whole.channels[0][window.startSample + sampleIndex],
|
|
2e-6,
|
|
`${label} sample ${sampleIndex}`,
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
test("interior output crop은 tone·sampled source·ambient의 whole-scene slice와 동치다", () => {
|
|
const sampleRate = 1_024;
|
|
const scene = {
|
|
sampleRate,
|
|
duration: 1,
|
|
speedOfSound: 343,
|
|
seed: 417,
|
|
mix: { normalization: "none", targetPeak: 0.95 },
|
|
sources: [
|
|
{
|
|
id: "aliased-tone",
|
|
position: [0.5, -0.1, 0.3],
|
|
gainDb: -18,
|
|
startTime: 0.031,
|
|
duration: 0.81,
|
|
signal: { type: "tone", frequency: 700, phaseDeg: 23 },
|
|
},
|
|
{
|
|
id: "sampled-noise",
|
|
position: [-0.2, 0.4, 0.6],
|
|
gainDb: -24,
|
|
startTime: 0.017,
|
|
duration: 0.9,
|
|
signal: { type: "noise", color: "pink", seedOffset: 7 },
|
|
},
|
|
{
|
|
id: "sampled-asset",
|
|
position: [0.1, 0.2, 0.7],
|
|
gainDb: -12,
|
|
startTime: 0.05,
|
|
duration: 0.8,
|
|
signal: { type: "asset", assetId: "interior", channel: 0, loop: true },
|
|
},
|
|
],
|
|
ambient: [{
|
|
id: "sampled-ambient",
|
|
gainDb: -30,
|
|
signal: { type: "noise", color: "white", seedOffset: 13 },
|
|
}],
|
|
};
|
|
const arrayDefinition = {
|
|
microphones: [
|
|
{ id: "left", position: [0, 0, 0] },
|
|
{
|
|
id: "right",
|
|
position: [0.23, -0.11, 0],
|
|
sensitivityDb: 1.5,
|
|
phaseOffsetDeg: -17,
|
|
frequencyResponse: [
|
|
{ frequencyHz: 0, gainDb: -1, phaseDeg: 5 },
|
|
{ frequencyHz: 512, gainDb: 2, phaseDeg: -11 },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const assetBuffers = new Map([["interior", {
|
|
sampleRate: 800,
|
|
channels: [Float32Array.from(
|
|
{ length: 173 },
|
|
(_, index) => Math.sin((2 * Math.PI * index) / 31) * 0.6,
|
|
)],
|
|
}]]);
|
|
const whole = synthesizeMicrophoneSignals({
|
|
scene,
|
|
arrayDefinition,
|
|
assetBuffers,
|
|
});
|
|
const cropStartSample = 173;
|
|
const cropSampleCount = 211;
|
|
const cropped = synthesizeMicrophoneSignals({
|
|
scene,
|
|
arrayDefinition,
|
|
assetBuffers,
|
|
signalOffsets: {
|
|
sceneSampleOffset: 0,
|
|
cropStartSample,
|
|
cropSampleCount,
|
|
},
|
|
});
|
|
assertEqual(cropped.channels[0].length, cropSampleCount, "crop-sized microphone output");
|
|
assertEqual(
|
|
cropped.workingSet.channelBytes,
|
|
arrayDefinition.microphones.length * cropSampleCount * 4,
|
|
"only crop-sized channels are retained",
|
|
);
|
|
cropped.channels.forEach((channel, microphoneIndex) => {
|
|
channel.forEach((value, sampleIndex) => {
|
|
assertNear(
|
|
value,
|
|
whole.channels[microphoneIndex][cropStartSample + sampleIndex],
|
|
4e-5,
|
|
`interior crop mic ${microphoneIndex} sample ${sampleIndex}`,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
test("source-streaming working set은 source 수가 아니라 한 sampled source의 Dmax로 결정된다", () => {
|
|
const sampleRate = 1_000;
|
|
const sampledSources = Array.from({ length: 20 }, (_, index) => ({
|
|
id: `noise-${index}`,
|
|
position: [1, 0, 0],
|
|
gainDb: -40,
|
|
startTime: 0,
|
|
duration: 0.062,
|
|
signal: { type: "noise", color: "white", seedOffset: index },
|
|
}));
|
|
const scene = {
|
|
sampleRate,
|
|
duration: 0.062,
|
|
speedOfSound: 1_000,
|
|
seed: 9,
|
|
mix: { normalization: "none", targetPeak: 0.95 },
|
|
sources: [
|
|
{
|
|
id: "far-analytic-tone",
|
|
position: [100, 0, 0],
|
|
gainDb: -60,
|
|
startTime: 0,
|
|
duration: 0.062,
|
|
signal: { type: "tone", frequency: 137, phaseDeg: 0 },
|
|
},
|
|
...sampledSources,
|
|
],
|
|
ambient: [
|
|
{ id: "ambient-a", gainDb: -60, signal: { type: "noise", color: "white" } },
|
|
{ id: "ambient-b", gainDb: -60, signal: { type: "noise", color: "pink" } },
|
|
],
|
|
};
|
|
const arrayDefinition = {
|
|
microphones: [
|
|
{ id: "mic-a", position: [0, 0, 0] },
|
|
{ id: "mic-b", position: [0, 0, 0] },
|
|
],
|
|
};
|
|
const signalOffsets = {
|
|
sceneSampleOffset: 0,
|
|
cropStartSample: 10,
|
|
cropSampleCount: 7,
|
|
};
|
|
const estimate = estimateSynthesisWorkingSet({
|
|
scene,
|
|
arrayDefinition,
|
|
signalOffsets,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
});
|
|
assertEqual(estimate.channelBytes, 56, "two seven-sample Float32 channels");
|
|
assertEqual(estimate.maximumTransient.maximumDelaySamples, 1, "sampled source Dmax");
|
|
assertEqual(estimate.maximumTransient.fftSize, 128, "source-specific delay FFT size");
|
|
assertEqual(estimate.maximumTransientBytes, 4_344, "one samples+spectrum+workspace");
|
|
assertEqual(estimate.estimatedBufferBytes, 4_400, "exact peak typed working set");
|
|
assertEqual(
|
|
estimate.acousticPathSummary.maximumDelaySeconds,
|
|
0.1,
|
|
"far analytic tone remains in physical path metadata",
|
|
);
|
|
|
|
const synthesis = synthesizeMicrophoneSignals({
|
|
scene,
|
|
arrayDefinition,
|
|
signalOffsets,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
maximumBufferBytes: 4_400,
|
|
});
|
|
assertEqual(synthesis.estimatedBufferBytes, 4_400, "runtime uses the shared estimate");
|
|
assertEqual(synthesis.channels[0].length, 7, "runtime cap succeeds at equality");
|
|
let error;
|
|
try {
|
|
synthesizeMicrophoneSignals({
|
|
scene,
|
|
arrayDefinition,
|
|
signalOffsets,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
maximumBufferBytes: 4_399,
|
|
});
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
assert(error?.message.includes("한도"), "one byte below exact peak is rejected");
|
|
});
|
|
|
|
test("far analytic tone은 live sampled source·ambient render pre-roll과 FFT를 키우지 않는다", () => {
|
|
const sampleRate = 1_000;
|
|
const scene = {
|
|
sampleRate,
|
|
duration: 6,
|
|
speedOfSound: 100,
|
|
seed: 77,
|
|
mix: { normalization: "none", targetPeak: 0.95 },
|
|
sources: [
|
|
{
|
|
id: "far-tone",
|
|
position: [500, 0, 0],
|
|
gainDb: -30,
|
|
startTime: 0,
|
|
duration: 6,
|
|
signal: { type: "tone", frequency: 137, phaseDeg: 19 },
|
|
},
|
|
{
|
|
id: "near-noise",
|
|
position: [1, 0, 0],
|
|
gainDb: -24,
|
|
startTime: 0,
|
|
duration: 6,
|
|
signal: { type: "noise", color: "white", seedOffset: 3 },
|
|
},
|
|
],
|
|
ambient: [{
|
|
id: "ambient",
|
|
gainDb: -36,
|
|
signal: { type: "noise", color: "pink", seedOffset: 5 },
|
|
}],
|
|
};
|
|
const arrayDefinition = {
|
|
microphones: [{ id: "mic", position: [0, 0, 0] }],
|
|
};
|
|
const whole = synthesizeMicrophoneSignals({
|
|
scene,
|
|
arrayDefinition,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
});
|
|
const window = sampleQuasiStaticWindow(scene, 5.5, 5.6, {
|
|
arrayDefinition,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
});
|
|
assertNear(window.maximumPropagationDelaySeconds, 5, 1e-12, "physical global Dmax");
|
|
assertNear(
|
|
window.renderMaximumPropagationDelaySeconds,
|
|
0.01,
|
|
1e-12,
|
|
"sampled render Dmax excludes analytic tone",
|
|
);
|
|
assertEqual(window.preRollSamples, 10, "only sampled propagation needs pre-roll samples");
|
|
assertEqual(window.scene.duration * sampleRate, 110, "render buffer is crop plus sampled Dmax");
|
|
const partial = synthesizeMicrophoneSignals({
|
|
scene: window.scene,
|
|
arrayDefinition,
|
|
signalOffsets: window.signalOffsets,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
});
|
|
assertEqual(partial.workingSet.maximumTransientId, "near-noise", "near sampled source sets peak");
|
|
assertEqual(partial.workingSet.maximumTransientBytes, 110 * 4 + 128 * 32, "small source FFT");
|
|
assertNear(
|
|
partial.windowing.renderMaximumPropagationDelaySeconds,
|
|
0.01,
|
|
1e-12,
|
|
"synthesis records actual sampled pre-roll Dmax",
|
|
);
|
|
partial.channels[0].forEach((value, sampleIndex) => {
|
|
assertNear(
|
|
value,
|
|
whole.channels[0][window.startSample + sampleIndex],
|
|
4e-5,
|
|
`far-tone live crop sample ${sampleIndex}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("live path summary는 source subset에 맞춰 불변 재사용되고 stale geometry에는 재사용되지 않는다", () => {
|
|
const scene = {
|
|
sampleRate: 2_000,
|
|
duration: 1,
|
|
speedOfSound: 100,
|
|
seed: 31,
|
|
mix: { normalization: "none", targetPeak: 0.95 },
|
|
sources: [{
|
|
id: "moving-noise",
|
|
position: [2, 0, 0],
|
|
gainDb: -24,
|
|
startTime: 0,
|
|
duration: 1,
|
|
signal: { type: "noise", color: "white" },
|
|
}],
|
|
ambient: [],
|
|
};
|
|
const arrayDefinition = {
|
|
microphones: [{ id: "mic", position: [0, 0, 0] }],
|
|
};
|
|
const window = sampleQuasiStaticWindow(scene, 0.3, 0.4, {
|
|
arrayDefinition,
|
|
});
|
|
const summary = window.signalOffsets.acousticPathSummary;
|
|
assert(Object.isFrozen(summary), "path summary is immutable");
|
|
assert(Object.isFrozen(summary.sources), "source summaries are immutable");
|
|
const synthesis = synthesizeMicrophoneSignals({
|
|
scene: window.scene,
|
|
arrayDefinition,
|
|
signalOffsets: window.signalOffsets,
|
|
});
|
|
assertEqual(
|
|
synthesis.workingSet.pathSummary,
|
|
"reused-authenticated-geometry-summary",
|
|
"live synthesis reuses its authenticated summary",
|
|
);
|
|
assertEqual(
|
|
synthesis.workingSet.summaryPathPairEvaluations,
|
|
0,
|
|
"synthesis does not repeat its preflight summary enumeration",
|
|
);
|
|
assertEqual(
|
|
synthesis.workingSet.synthesisPathBuildPairEvaluations,
|
|
1,
|
|
"each selected source/receiver path set is built once for synthesis",
|
|
);
|
|
const movedArray = structuredClone(arrayDefinition);
|
|
movedArray.microphones[0].position[0] = 0.5;
|
|
const stale = estimateSynthesisWorkingSet({
|
|
scene: window.scene,
|
|
arrayDefinition: movedArray,
|
|
signalOffsets: window.signalOffsets,
|
|
});
|
|
assert(!stale.reusedPathSummary, "changed receiver geometry invalidates the summary");
|
|
|
|
const originalSummary = summarizeAcousticPaths({ scene, arrayDefinition });
|
|
assertEqual(
|
|
summarizeAcousticPaths({ scene, arrayDefinition }),
|
|
originalSummary,
|
|
"unchanged geometry reuses the internally cached exact summary",
|
|
);
|
|
assertEqual(
|
|
summarizeAcousticPaths({
|
|
scene: structuredClone(scene),
|
|
arrayDefinition: structuredClone(arrayDefinition),
|
|
}),
|
|
originalSummary,
|
|
"bounded signature cache also reuses validated structured clones",
|
|
);
|
|
const movedScene = structuredClone(scene);
|
|
movedScene.sources[0].position[0] = 8;
|
|
let staleRetargetError;
|
|
try {
|
|
selectAcousticPathSummary({
|
|
summary: originalSummary,
|
|
sourceIndexes: [0],
|
|
sourceScene: scene,
|
|
scene: movedScene,
|
|
arrayDefinition,
|
|
});
|
|
} catch (error) {
|
|
staleRetargetError = error;
|
|
}
|
|
assert(
|
|
staleRetargetError?.message.includes("selected scene path geometry"),
|
|
"selector cannot rebrand stale numeric delays for new geometry",
|
|
);
|
|
|
|
let insufficientDelayError;
|
|
try {
|
|
sampleQuasiStaticWindow(scene, 0.3, 0.4, {
|
|
arrayDefinition,
|
|
maximumPropagationDelaySeconds: 0.001,
|
|
});
|
|
} catch (error) {
|
|
insufficientDelayError = error;
|
|
}
|
|
assert(
|
|
insufficientDelayError?.message.includes("shorter than"),
|
|
"undersized explicit pre-roll is rejected",
|
|
);
|
|
});
|
|
|
|
test("non-loop asset arrival truth는 decoded channel 종료 시각에서 끝난다", () => {
|
|
const sampleRate = 1_000;
|
|
const scene = {
|
|
sampleRate,
|
|
duration: 1,
|
|
speedOfSound: 100,
|
|
seed: 5,
|
|
mix: { normalization: "none", targetPeak: 0.95 },
|
|
sources: [{
|
|
id: "short-asset",
|
|
position: [10, 0, 0],
|
|
gainDb: -12,
|
|
startTime: 0,
|
|
duration: 0.5,
|
|
signal: { type: "asset", assetId: "short", channel: 0, loop: false },
|
|
}],
|
|
ambient: [],
|
|
};
|
|
const arrayDefinition = {
|
|
microphones: [{ id: "mic", position: [0, 0, 0] }],
|
|
};
|
|
const assetBuffers = new Map([["short", {
|
|
sampleRate,
|
|
channels: [Float32Array.from(
|
|
{ length: 50 },
|
|
(_, index) => Math.sin((2 * Math.PI * index) / 11),
|
|
)],
|
|
}]]);
|
|
const whole = synthesizeMicrophoneSignals({
|
|
scene,
|
|
arrayDefinition,
|
|
assetBuffers,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
});
|
|
const arrival = whole.windowing.sourceArrivals[0];
|
|
assertEqual(arrival.emissionEndSample, 50, "decoded channel limits emission");
|
|
assertEqual(arrival.earliestArrivalSample, 100, "direct arrival start");
|
|
assertEqual(arrival.latestArrivalEndSample, 150, "decoded tail arrival end");
|
|
|
|
const arrivingWindow = sampleQuasiStaticWindow(scene, 0.12, 0.14, {
|
|
arrayDefinition,
|
|
assetBuffers,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
});
|
|
const arriving = synthesizeMicrophoneSignals({
|
|
scene: arrivingWindow.scene,
|
|
arrayDefinition,
|
|
assetBuffers,
|
|
signalOffsets: arrivingWindow.signalOffsets,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
});
|
|
assertEqual(
|
|
arriving.windowing.arrivingSourceIds.join(","),
|
|
"short-asset",
|
|
"decoded tail overlaps the early output",
|
|
);
|
|
const silentWindow = sampleQuasiStaticWindow(scene, 0.3, 0.4, {
|
|
arrayDefinition,
|
|
assetBuffers,
|
|
referenceDistance: 1,
|
|
minimumDistance: 1,
|
|
});
|
|
assertEqual(silentWindow.scene.sources.length, 0, "expired decoded asset is not retained");
|
|
});
|
|
|
|
test("거대 acoustic delay와 path evaluation budget은 typed channel 할당 전에 거부된다", () => {
|
|
const scene = {
|
|
sampleRate: 8_000,
|
|
duration: 0.01,
|
|
speedOfSound: 343,
|
|
seed: 1,
|
|
mix: { normalization: "none", targetPeak: 0.95 },
|
|
sources: [{
|
|
id: "astronomical",
|
|
position: [Number.MAX_VALUE / 4, 0, 0],
|
|
gainDb: -30,
|
|
startTime: 0,
|
|
duration: 0.01,
|
|
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
|
|
}],
|
|
ambient: [],
|
|
};
|
|
const arrayDefinition = {
|
|
microphones: [
|
|
{ id: "mic-a", position: [0, 0, 0] },
|
|
{ id: "mic-b", position: [0, 0, 0] },
|
|
],
|
|
};
|
|
let delayError;
|
|
try {
|
|
synthesizeMicrophoneSignals({ scene, arrayDefinition });
|
|
} catch (error) {
|
|
delayError = error;
|
|
}
|
|
assert(
|
|
delayError?.message.includes("sample position"),
|
|
"unsafe path delay fails during preflight",
|
|
);
|
|
|
|
const boundedScene = structuredClone(scene);
|
|
boundedScene.sources[0].position = [1, 0, 0];
|
|
let budgetError;
|
|
try {
|
|
synthesizeMicrophoneSignals({
|
|
scene: boundedScene,
|
|
arrayDefinition,
|
|
maximumPathEvaluations: 1,
|
|
});
|
|
} catch (error) {
|
|
budgetError = error;
|
|
}
|
|
assert(
|
|
budgetError?.message.includes("maximumPathEvaluations"),
|
|
"path evaluation cap fails before path objects accumulate",
|
|
);
|
|
|
|
let invalidBudgetError;
|
|
try {
|
|
estimateSynthesisWorkingSet({
|
|
scene: boundedScene,
|
|
arrayDefinition,
|
|
maximumPathEvaluations: 1.5,
|
|
});
|
|
} catch (error) {
|
|
invalidBudgetError = error;
|
|
}
|
|
assert(
|
|
invalidBudgetError?.message.includes("positive safe integer"),
|
|
"path evaluation budget itself is validated",
|
|
);
|
|
|
|
let absoluteRangeError;
|
|
try {
|
|
estimateSynthesisWorkingSet({
|
|
scene: boundedScene,
|
|
arrayDefinition,
|
|
signalOffsets: {
|
|
sceneSampleOffset: Number.MAX_SAFE_INTEGER,
|
|
cropStartSample: 1,
|
|
cropSampleCount: 1,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
absoluteRangeError = error;
|
|
}
|
|
assert(
|
|
absoluteRangeError?.message.includes("absolute sample range"),
|
|
"absolute output sample addition is checked before synthesis",
|
|
);
|
|
});
|