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", "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", ); assert(isDasAlgorithm("das") && isDasAlgorithm("das-phat"), "DAS engine algorithms"); assert(!isDasAlgorithm("gcc-phat"), "GCC-PHAT 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", ); });