From 6280fa4dfdfbbbda957350ababfb3206aaf10e3d Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 16:05:22 +0900 Subject: [PATCH] feat: synthesize trajectory analysis windows --- packages/acoustics-core/propagation.js | 14 +++- packages/acoustics-core/signal.js | 28 +++++++- packages/acoustics-core/trajectory.js | 70 +++++++++++++++++-- tests/unit/acoustics.test.js | 97 ++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 11 deletions(-) diff --git a/packages/acoustics-core/propagation.js b/packages/acoustics-core/propagation.js index 87f6a7e..27d3af8 100644 --- a/packages/acoustics-core/propagation.js +++ b/packages/acoustics-core/propagation.js @@ -23,6 +23,7 @@ export function synthesizeMicrophoneSignals(options) { referenceDistance = 1, minimumDistance = 0.01, maximumBufferBytes = 512 * 1024 * 1024, + signalOffsets, } = options; const sampleCount = Math.ceil(scene.duration * scene.sampleRate); const retainedBufferCount = arrayDefinition.microphones.length + scene.sources.length + 1; @@ -46,9 +47,13 @@ export function synthesizeMicrophoneSignals(options) { ); } const channels = arrayDefinition.microphones.map(() => new Float32Array(sampleCount)); - const renderedSources = scene.sources.map((source) => ({ + const sceneSampleOffset = signalOffsets?.sceneSampleOffset ?? 0; + const renderedSources = scene.sources.map((source, sourceIndex) => ({ definition: source, - samples: renderSignal(source, scene, assetBuffers), + samples: renderSignal(source, scene, assetBuffers, 0, { + sceneSampleOffset, + ...(signalOffsets?.sources?.[sourceIndex] ?? {}), + }), })); renderedSources.forEach(({ definition, samples }) => { @@ -77,7 +82,10 @@ export function synthesizeMicrophoneSignals(options) { channels.forEach((channel, microphoneIndex) => { const microphone = arrayDefinition.microphones[microphoneIndex]; const seedSalt = Math.imul(ambientIndex + 1, 0x45d9f3b) ^ Math.imul(microphoneIndex + 1, 0x27d4eb2d); - const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt); + const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt, { + sceneSampleOffset, + ...(signalOffsets?.ambient?.[ambientIndex] ?? {}), + }); const spectrum = prepareDelaySpectrum(ambientSamples, 0); addFrequencyDomainDelay({ output: channel, diff --git a/packages/acoustics-core/signal.js b/packages/acoustics-core/signal.js index ed7200b..19a5841 100644 --- a/packages/acoustics-core/signal.js +++ b/packages/acoustics-core/signal.js @@ -15,7 +15,16 @@ function sampleAsset(signal, assetBuffers, time) { return channel[left] + (rightValue - channel[left]) * fraction; } -export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt = 0) { +/** + * Render one signal entry into the scene-sized output buffer. + * + * `timeMapping` lets a short, rebased scene window retain the original signal + * timeline. `sceneSampleOffset` maps output sample zero to an absolute sample + * in the original scene, while `signalStartTime`/`signalStartSample` preserve + * the entry's original tone/asset time origin and deterministic noise index. + * Omitting it keeps the historical whole-scene behaviour exactly unchanged. + */ +export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt = 0, timeMapping = {}) { const sampleCount = Math.ceil(scene.duration * scene.sampleRate); const output = new Float32Array(sampleCount); const startTime = entry.startTime ?? 0; @@ -23,12 +32,25 @@ export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt = const gain = dbToLinear(entry.gainDb ?? 0); const startSample = Math.max(0, Math.ceil(startTime * scene.sampleRate)); const endSample = Math.min(sampleCount, Math.ceil((startTime + duration) * scene.sampleRate)); + const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0; + const signalStartTime = timeMapping.signalStartTime ?? startTime; + const signalStartSample = timeMapping.signalStartSample ?? Math.ceil(startTime * scene.sampleRate); + if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) { + throw new RangeError("sceneSampleOffset must be a non-negative safe integer."); + } + if (!Number.isFinite(signalStartTime)) { + throw new TypeError("signalStartTime must be finite."); + } + if (!Number.isSafeInteger(signalStartSample) || signalStartSample < 0) { + throw new RangeError("signalStartSample must be a non-negative safe integer."); + } const signal = entry.signal; const seed = (scene.seed + (signal.seedOffset ?? 0) + hashString(entry.id ?? "signal") + seedSalt) >>> 0; for (let sampleIndex = startSample; sampleIndex < endSample; sampleIndex += 1) { - const localIndex = sampleIndex - startSample; - const localTime = sampleIndex / scene.sampleRate - startTime; + const absoluteSampleIndex = sceneSampleOffset + sampleIndex; + const localIndex = absoluteSampleIndex - signalStartSample; + const localTime = absoluteSampleIndex / scene.sampleRate - signalStartTime; let value; if (signal.type === "tone") { value = Math.sin(TAU * signal.frequency * localTime + ((signal.phaseDeg ?? 0) * Math.PI) / 180); diff --git a/packages/acoustics-core/trajectory.js b/packages/acoustics-core/trajectory.js index 0e8481e..48b4674 100644 --- a/packages/acoustics-core/trajectory.js +++ b/packages/acoustics-core/trajectory.js @@ -74,13 +74,73 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime) { if (Number.isFinite(scene?.duration) && endTime > scene.duration) { throw new RangeError("window endTime must not exceed scene.duration."); } - const sampleTime = startTime + (endTime - startTime) / 2; + const sampleRate = scene?.sampleRate; + if (!Number.isInteger(sampleRate) || sampleRate <= 0) { + const sampleTime = startTime + (endTime - startTime) / 2; + return { + startTime, + endTime, + sampleTime, + duration: endTime - startTime, + scene: sceneAtTime(scene, sampleTime), + }; + } + + // Ceil selects the first sample whose timestamp is inside each half-open + // boundary. Adjacent windows therefore share an exact boundary without a + // duplicated or omitted sample, even when their requested times are not + // integer multiples of the sample period. + const startSample = Math.ceil(startTime * sampleRate); + const endSample = Math.ceil(endTime * sampleRate); + if (endSample <= startSample) { + throw new RangeError("window must contain at least one sample."); + } + const alignedStartTime = startSample / sampleRate; + const alignedEndTime = endSample / sampleRate; + const sampleTime = (startSample + endSample) / (2 * sampleRate); + const sources = []; + const sourceOffsets = []; + + for (const source of scene.sources) { + const signalStartTime = source.startTime ?? 0; + const signalDuration = source.duration ?? scene.duration; + const signalStartSample = Math.ceil(signalStartTime * sampleRate); + const signalEndSample = Math.ceil((signalStartTime + signalDuration) * sampleRate); + const activeStartSample = Math.max(startSample, signalStartSample); + const activeEndSample = Math.min(endSample, signalEndSample); + if (activeEndSample <= activeStartSample) continue; + + sources.push({ + ...source, + position: sourcePositionAtTime(source, sampleTime), + startTime: (activeStartSample - startSample) / sampleRate, + duration: (activeEndSample - activeStartSample) / sampleRate, + }); + sourceOffsets.push({ signalStartTime, signalStartSample }); + } + + const ambient = Array.isArray(scene.ambient) ? scene.ambient.slice() : scene.ambient; + const signalOffsets = { + sceneSampleOffset: startSample, + sources: sourceOffsets, + ambient: (scene.ambient ?? []).map(() => ({ signalStartTime: 0, signalStartSample: 0 })), + }; + const duration = (endSample - startSample) / sampleRate; return { - startTime, - endTime, + startTime: alignedStartTime, + endTime: alignedEndTime, sampleTime, - duration: endTime - startTime, - scene: sceneAtTime(scene, sampleTime), + startSample, + endSample, + sampleCount: endSample - startSample, + duration, + signalOffsets, + scene: { + ...scene, + duration, + sources, + ...(Array.isArray(scene.ambient) ? { ambient } : {}), + }, }; } diff --git a/tests/unit/acoustics.test.js b/tests/unit/acoustics.test.js index 7881958..3e7d8bf 100644 --- a/tests/unit/acoustics.test.js +++ b/tests/unit/acoustics.test.js @@ -9,9 +9,11 @@ import { dbToLinear, distance3 } from "/packages/acoustics-core/math.js"; import { renderSceneMix } from "/packages/acoustics-core/mixdown.js"; import { whiteNoiseAt } from "/packages/acoustics-core/noise.js"; import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js"; +import { renderSignal } from "/packages/acoustics-core/signal.js"; import { getFrameCount } from "/packages/acoustics-core/stft.js"; import { createQuasiStaticWindows, + sampleQuasiStaticWindow, sceneAtTime, sourcePositionAtTime, } from "/packages/acoustics-core/trajectory.js"; @@ -441,3 +443,98 @@ test("PCM16 WAV가 표준 mono header를 쓰고 범위 밖 sample을 clamp한다 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}`); + }); +});