feat: synthesize trajectory analysis windows
This commit is contained in:
@@ -23,6 +23,7 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
referenceDistance = 1,
|
referenceDistance = 1,
|
||||||
minimumDistance = 0.01,
|
minimumDistance = 0.01,
|
||||||
maximumBufferBytes = 512 * 1024 * 1024,
|
maximumBufferBytes = 512 * 1024 * 1024,
|
||||||
|
signalOffsets,
|
||||||
} = options;
|
} = options;
|
||||||
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
||||||
const retainedBufferCount = arrayDefinition.microphones.length + scene.sources.length + 1;
|
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 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,
|
definition: source,
|
||||||
samples: renderSignal(source, scene, assetBuffers),
|
samples: renderSignal(source, scene, assetBuffers, 0, {
|
||||||
|
sceneSampleOffset,
|
||||||
|
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
renderedSources.forEach(({ definition, samples }) => {
|
renderedSources.forEach(({ definition, samples }) => {
|
||||||
@@ -77,7 +82,10 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
channels.forEach((channel, microphoneIndex) => {
|
channels.forEach((channel, microphoneIndex) => {
|
||||||
const microphone = arrayDefinition.microphones[microphoneIndex];
|
const microphone = arrayDefinition.microphones[microphoneIndex];
|
||||||
const seedSalt = Math.imul(ambientIndex + 1, 0x45d9f3b) ^ Math.imul(microphoneIndex + 1, 0x27d4eb2d);
|
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);
|
const spectrum = prepareDelaySpectrum(ambientSamples, 0);
|
||||||
addFrequencyDomainDelay({
|
addFrequencyDomainDelay({
|
||||||
output: channel,
|
output: channel,
|
||||||
|
|||||||
@@ -15,7 +15,16 @@ function sampleAsset(signal, assetBuffers, time) {
|
|||||||
return channel[left] + (rightValue - channel[left]) * fraction;
|
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 sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
||||||
const output = new Float32Array(sampleCount);
|
const output = new Float32Array(sampleCount);
|
||||||
const startTime = entry.startTime ?? 0;
|
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 gain = dbToLinear(entry.gainDb ?? 0);
|
||||||
const startSample = Math.max(0, Math.ceil(startTime * scene.sampleRate));
|
const startSample = Math.max(0, Math.ceil(startTime * scene.sampleRate));
|
||||||
const endSample = Math.min(sampleCount, Math.ceil((startTime + duration) * 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 signal = entry.signal;
|
||||||
const seed = (scene.seed + (signal.seedOffset ?? 0) + hashString(entry.id ?? "signal") + seedSalt) >>> 0;
|
const seed = (scene.seed + (signal.seedOffset ?? 0) + hashString(entry.id ?? "signal") + seedSalt) >>> 0;
|
||||||
|
|
||||||
for (let sampleIndex = startSample; sampleIndex < endSample; sampleIndex += 1) {
|
for (let sampleIndex = startSample; sampleIndex < endSample; sampleIndex += 1) {
|
||||||
const localIndex = sampleIndex - startSample;
|
const absoluteSampleIndex = sceneSampleOffset + sampleIndex;
|
||||||
const localTime = sampleIndex / scene.sampleRate - startTime;
|
const localIndex = absoluteSampleIndex - signalStartSample;
|
||||||
|
const localTime = absoluteSampleIndex / scene.sampleRate - signalStartTime;
|
||||||
let value;
|
let value;
|
||||||
if (signal.type === "tone") {
|
if (signal.type === "tone") {
|
||||||
value = Math.sin(TAU * signal.frequency * localTime + ((signal.phaseDeg ?? 0) * Math.PI) / 180);
|
value = Math.sin(TAU * signal.frequency * localTime + ((signal.phaseDeg ?? 0) * Math.PI) / 180);
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime) {
|
|||||||
if (Number.isFinite(scene?.duration) && endTime > scene.duration) {
|
if (Number.isFinite(scene?.duration) && endTime > scene.duration) {
|
||||||
throw new RangeError("window endTime must not exceed scene.duration.");
|
throw new RangeError("window endTime must not exceed scene.duration.");
|
||||||
}
|
}
|
||||||
|
const sampleRate = scene?.sampleRate;
|
||||||
|
if (!Number.isInteger(sampleRate) || sampleRate <= 0) {
|
||||||
const sampleTime = startTime + (endTime - startTime) / 2;
|
const sampleTime = startTime + (endTime - startTime) / 2;
|
||||||
return {
|
return {
|
||||||
startTime,
|
startTime,
|
||||||
@@ -82,6 +84,64 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime) {
|
|||||||
duration: endTime - startTime,
|
duration: endTime - startTime,
|
||||||
scene: sceneAtTime(scene, sampleTime),
|
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: alignedStartTime,
|
||||||
|
endTime: alignedEndTime,
|
||||||
|
sampleTime,
|
||||||
|
startSample,
|
||||||
|
endSample,
|
||||||
|
sampleCount: endSample - startSample,
|
||||||
|
duration,
|
||||||
|
signalOffsets,
|
||||||
|
scene: {
|
||||||
|
...scene,
|
||||||
|
duration,
|
||||||
|
sources,
|
||||||
|
...(Array.isArray(scene.ambient) ? { ambient } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import { dbToLinear, distance3 } from "/packages/acoustics-core/math.js";
|
|||||||
import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
|
import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
|
||||||
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
|
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
|
||||||
import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.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 { getFrameCount } from "/packages/acoustics-core/stft.js";
|
||||||
import {
|
import {
|
||||||
createQuasiStaticWindows,
|
createQuasiStaticWindows,
|
||||||
|
sampleQuasiStaticWindow,
|
||||||
sceneAtTime,
|
sceneAtTime,
|
||||||
sourcePositionAtTime,
|
sourcePositionAtTime,
|
||||||
} from "/packages/acoustics-core/trajectory.js";
|
} from "/packages/acoustics-core/trajectory.js";
|
||||||
@@ -441,3 +443,98 @@ test("PCM16 WAV가 표준 mono header를 쓰고 범위 밖 sample을 clamp한다
|
|||||||
assertEqual(actualSamples[index], expected, `PCM sample ${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}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user