import { computeBinMap, computeDasMap, createDistanceMatrix } from "/packages/acoustics-core/das.js"; import { fftInPlace, fftReal } from "/packages/acoustics-core/fft.js"; import { addFrequencyDomainDelay, 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 { getFrameCount } from "/packages/acoustics-core/stft.js"; import { createQuasiStaticWindows, 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("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("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("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("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; }); 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"); }); 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]}`); }); 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}`); }); });