751 lines
24 KiB
JavaScript
751 lines
24 KiB
JavaScript
import { interpolateFrequencyResponse } from "./frequency-response.js";
|
|
import {
|
|
ceilSampleIndex,
|
|
ceilSamplePosition,
|
|
dbToLinear,
|
|
distance3,
|
|
TAU,
|
|
} from "./math.js";
|
|
import {
|
|
addFrequencyDomainDelay,
|
|
addFrequencyDomainPaths,
|
|
createDelayWorkspace,
|
|
nextPowerOfTwo,
|
|
prepareDelaySpectrum,
|
|
} from "./fractional-delay.js";
|
|
import {
|
|
buildAcousticPaths,
|
|
DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
|
isAcousticPathSummaryCompatible,
|
|
summarizeAcousticPaths,
|
|
} from "./environment.js";
|
|
import {
|
|
effectiveSignalDurationSeconds,
|
|
renderSignal,
|
|
} from "./signal.js";
|
|
import { describeFrequencyAliasing } from "./sampling.js";
|
|
|
|
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
|
|
return distance3(sourcePosition, receiverPosition) / speedOfSound;
|
|
}
|
|
|
|
export function sphericalAttenuation(distance, referenceDistance = 1, minimumDistance = 0.01) {
|
|
return referenceDistance / Math.max(distance, minimumDistance);
|
|
}
|
|
|
|
function addToneEvent(
|
|
events,
|
|
sampleIndex,
|
|
sineDelta,
|
|
cosineDelta,
|
|
activePathDelta,
|
|
) {
|
|
const event = events.get(sampleIndex);
|
|
if (event) {
|
|
event.sine += sineDelta;
|
|
event.cosine += cosineDelta;
|
|
event.activePaths += activePathDelta;
|
|
} else {
|
|
events.set(sampleIndex, {
|
|
sine: sineDelta,
|
|
cosine: cosineDelta,
|
|
activePaths: activePathDelta,
|
|
});
|
|
}
|
|
}
|
|
|
|
function carrierAtSample(signedFrequencyHz, sampleRate, absoluteSampleIndex) {
|
|
const cycles = (signedFrequencyHz * absoluteSampleIndex) / sampleRate;
|
|
const fractionalCycles = cycles - Math.floor(cycles);
|
|
const phase = TAU * fractionalCycles;
|
|
return { cosine: Math.cos(phase), sine: Math.sin(phase) };
|
|
}
|
|
|
|
/**
|
|
* Apply propagation and microphone response to an analog tone before sampling.
|
|
*
|
|
* This order is essential above Nyquist: temporal samples fold to the ADC
|
|
* alias, while inter-microphone phase still comes from the original acoustic
|
|
* frequency and path delay.
|
|
*/
|
|
function addSampledAnalogTonePaths(options) {
|
|
const {
|
|
output,
|
|
entry,
|
|
scene,
|
|
microphone,
|
|
paths,
|
|
timeMapping = {},
|
|
outputSceneSampleOffset,
|
|
outputRenderSampleOffset = 0,
|
|
} = options;
|
|
const signal = entry.signal;
|
|
const frequencyHz = signal.frequency;
|
|
const sampling = describeFrequencyAliasing(frequencyHz, scene.sampleRate);
|
|
const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0;
|
|
const outputSampleOffset = outputSceneSampleOffset ?? sceneSampleOffset;
|
|
const signalStartTime = timeMapping.signalStartTime ?? entry.startTime ?? 0;
|
|
const signalDuration = timeMapping.signalDuration ?? entry.duration ?? scene.duration;
|
|
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
|
|
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
|
|
}
|
|
if (!Number.isSafeInteger(outputSampleOffset) || outputSampleOffset < 0) {
|
|
throw new RangeError("outputSceneSampleOffset must be a non-negative safe integer.");
|
|
}
|
|
if (
|
|
!Number.isSafeInteger(outputRenderSampleOffset) ||
|
|
outputRenderSampleOffset < 0
|
|
) {
|
|
throw new RangeError("outputRenderSampleOffset must be a non-negative safe integer.");
|
|
}
|
|
if (!Number.isFinite(signalStartTime)) {
|
|
throw new TypeError("signalStartTime must be finite.");
|
|
}
|
|
if (!Number.isFinite(signalDuration) || signalDuration <= 0) {
|
|
throw new RangeError("signalDuration must be a positive finite number.");
|
|
}
|
|
const sourcePhase = ((signal.phaseDeg ?? 0) * Math.PI) / 180;
|
|
const response = interpolateFrequencyResponse(
|
|
microphone.frequencyResponse,
|
|
frequencyHz,
|
|
{},
|
|
);
|
|
const microphonePhase =
|
|
((microphone.phaseOffsetDeg ?? 0) * Math.PI) / 180 + response.phaseRadians;
|
|
const baseGain =
|
|
dbToLinear(entry.gainDb ?? 0) *
|
|
dbToLinear(microphone.sensitivityDb ?? 0) *
|
|
response.linearGain;
|
|
const events = new Map();
|
|
|
|
for (const path of paths) {
|
|
const firstSample = Math.max(
|
|
0,
|
|
ceilSampleIndex(signalStartTime + path.delaySeconds, scene.sampleRate) -
|
|
outputSampleOffset,
|
|
);
|
|
const lastSample = Math.min(
|
|
output.length,
|
|
ceilSampleIndex(
|
|
signalStartTime + signalDuration + path.delaySeconds,
|
|
scene.sampleRate,
|
|
) - outputSampleOffset,
|
|
);
|
|
if (firstSample >= lastSample) continue;
|
|
const amplitude = baseGain * path.gain;
|
|
const delayedCycles = (frequencyHz * (signalStartTime + path.delaySeconds)) % 1;
|
|
const phase = sourcePhase + microphonePhase - TAU * delayedCycles;
|
|
const sineCoefficient = amplitude * Math.cos(phase);
|
|
const cosineCoefficient = amplitude * Math.sin(phase);
|
|
addToneEvent(events, firstSample, sineCoefficient, cosineCoefficient, 1);
|
|
addToneEvent(events, lastSample, -sineCoefficient, -cosineCoefficient, -1);
|
|
}
|
|
if (events.size === 0) return;
|
|
|
|
const orderedEvents = [...events.entries()].sort((left, right) => left[0] - right[0]);
|
|
const phaseStep =
|
|
(TAU * sampling.signedAdcFrequencyHz) / scene.sampleRate;
|
|
const stepReal = Math.cos(phaseStep);
|
|
const stepImaginary = Math.sin(phaseStep);
|
|
const firstSample = orderedEvents[0][0];
|
|
const finalEventSample = orderedEvents.at(-1)[0];
|
|
const initialCarrier = carrierAtSample(
|
|
sampling.signedAdcFrequencyHz,
|
|
scene.sampleRate,
|
|
outputSampleOffset + firstSample,
|
|
);
|
|
let carrierCosine = initialCarrier.cosine;
|
|
let carrierSine = initialCarrier.sine;
|
|
let sineCoefficient = 0;
|
|
let cosineCoefficient = 0;
|
|
let activePathCount = 0;
|
|
let eventIndex = 0;
|
|
const phaseReanchorInterval = 1024;
|
|
let sampleIndex = firstSample;
|
|
while (sampleIndex < output.length && sampleIndex < finalEventSample) {
|
|
while (
|
|
eventIndex < orderedEvents.length &&
|
|
orderedEvents[eventIndex][0] === sampleIndex
|
|
) {
|
|
sineCoefficient += orderedEvents[eventIndex][1].sine;
|
|
cosineCoefficient += orderedEvents[eventIndex][1].cosine;
|
|
activePathCount += orderedEvents[eventIndex][1].activePaths;
|
|
eventIndex += 1;
|
|
}
|
|
if (activePathCount === 0 && eventIndex < orderedEvents.length) {
|
|
sampleIndex = orderedEvents[eventIndex][0];
|
|
const carrier = carrierAtSample(
|
|
sampling.signedAdcFrequencyHz,
|
|
scene.sampleRate,
|
|
outputSampleOffset + sampleIndex,
|
|
);
|
|
carrierCosine = carrier.cosine;
|
|
carrierSine = carrier.sine;
|
|
continue;
|
|
}
|
|
output[sampleIndex] +=
|
|
carrierSine * sineCoefficient + carrierCosine * cosineCoefficient;
|
|
|
|
const nextSample = sampleIndex + 1;
|
|
if (
|
|
(outputRenderSampleOffset + nextSample) % phaseReanchorInterval === 0
|
|
) {
|
|
const carrier = carrierAtSample(
|
|
sampling.signedAdcFrequencyHz,
|
|
scene.sampleRate,
|
|
outputSampleOffset + nextSample,
|
|
);
|
|
carrierCosine = carrier.cosine;
|
|
carrierSine = carrier.sine;
|
|
} else {
|
|
const nextCosine =
|
|
carrierCosine * stepReal - carrierSine * stepImaginary;
|
|
carrierSine =
|
|
carrierSine * stepReal + carrierCosine * stepImaginary;
|
|
carrierCosine = nextCosine;
|
|
}
|
|
sampleIndex = nextSample;
|
|
}
|
|
}
|
|
|
|
export const DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES = 512 * 1024 * 1024;
|
|
|
|
function resolveSynthesisDimensions(scene, signalOffsets) {
|
|
const sampleCount = ceilSampleIndex(scene.duration, scene.sampleRate);
|
|
const sceneSampleOffset = signalOffsets?.sceneSampleOffset ?? 0;
|
|
const cropStartSample = signalOffsets?.cropStartSample ?? 0;
|
|
const cropSampleCount = signalOffsets?.cropSampleCount ?? sampleCount;
|
|
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
|
|
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
|
|
}
|
|
if (
|
|
!Number.isSafeInteger(cropStartSample) ||
|
|
cropStartSample < 0 ||
|
|
!Number.isSafeInteger(cropSampleCount) ||
|
|
cropSampleCount <= 0 ||
|
|
cropStartSample + cropSampleCount > sampleCount
|
|
) {
|
|
throw new RangeError("signal output crop must be a non-empty range inside the synthesis buffer.");
|
|
}
|
|
const outputStartSample = sceneSampleOffset + cropStartSample;
|
|
const outputEndSample = outputStartSample + cropSampleCount;
|
|
if (
|
|
!Number.isSafeInteger(outputStartSample) ||
|
|
!Number.isSafeInteger(outputEndSample)
|
|
) {
|
|
throw new RangeError("signal output absolute sample range exceeds the safe integer range.");
|
|
}
|
|
return {
|
|
sampleCount,
|
|
sceneSampleOffset,
|
|
cropStartSample,
|
|
cropSampleCount,
|
|
outputStartSample,
|
|
};
|
|
}
|
|
|
|
function sampledSignalTransient(sampleCount, maximumDelaySeconds, sampleRate) {
|
|
const maximumDelaySamples = ceilSamplePosition(maximumDelaySeconds * sampleRate);
|
|
const fftSize = nextPowerOfTwo(sampleCount + maximumDelaySamples + 2);
|
|
const sampleBytes = sampleCount * Float32Array.BYTES_PER_ELEMENT;
|
|
const spectrumAndWorkspaceBytes =
|
|
fftSize * Float64Array.BYTES_PER_ELEMENT * 4;
|
|
return {
|
|
maximumDelaySamples,
|
|
fftSize,
|
|
sampleBytes,
|
|
spectrumAndWorkspaceBytes,
|
|
totalBytes: sampleBytes + spectrumAndWorkspaceBytes,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Estimate the maximum simultaneously live typed-array storage used by the
|
|
* source-streaming synthesizer. Paths are represented separately by bounded
|
|
* short-lived JS objects and are not included in this typed working-set cap.
|
|
*/
|
|
export function estimateSynthesisWorkingSet(options) {
|
|
const {
|
|
scene,
|
|
arrayDefinition,
|
|
referenceDistance = 1,
|
|
minimumDistance = 0.01,
|
|
signalOffsets,
|
|
maximumPathEvaluations = DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
|
} = options;
|
|
if (
|
|
!Number.isSafeInteger(maximumPathEvaluations) ||
|
|
maximumPathEvaluations <= 0
|
|
) {
|
|
throw new RangeError(
|
|
"maximumPathEvaluations must be a positive safe integer.",
|
|
);
|
|
}
|
|
const dimensions = resolveSynthesisDimensions(scene, signalOffsets);
|
|
const summaryOptions = {
|
|
scene,
|
|
arrayDefinition,
|
|
referenceDistance,
|
|
minimumDistance,
|
|
};
|
|
const candidateSummary =
|
|
options.acousticPathSummary ?? signalOffsets?.acousticPathSummary;
|
|
const reusedPathSummary = isAcousticPathSummaryCompatible(
|
|
candidateSummary,
|
|
summaryOptions,
|
|
);
|
|
const acousticPathSummary = reusedPathSummary
|
|
? candidateSummary
|
|
: summarizeAcousticPaths({
|
|
...summaryOptions,
|
|
maximumPathEvaluations,
|
|
});
|
|
if (acousticPathSummary.pathCount > maximumPathEvaluations) {
|
|
throw new RangeError(
|
|
`acoustic path count ${acousticPathSummary.pathCount} exceeds ` +
|
|
`maximumPathEvaluations ${maximumPathEvaluations}.`,
|
|
);
|
|
}
|
|
const activePathMaximumDelaySamples = ceilSamplePosition(
|
|
acousticPathSummary.maximumDelaySeconds * scene.sampleRate,
|
|
);
|
|
|
|
const channelBytes =
|
|
arrayDefinition.microphones.length *
|
|
dimensions.cropSampleCount *
|
|
Float32Array.BYTES_PER_ELEMENT;
|
|
let maximumTransientBytes = 0;
|
|
let maximumTransient = {
|
|
scope: "none",
|
|
id: null,
|
|
maximumDelaySamples: 0,
|
|
fftSize: 0,
|
|
sampleBytes: 0,
|
|
spectrumAndWorkspaceBytes: 0,
|
|
totalBytes: 0,
|
|
};
|
|
const sampledSources = [];
|
|
scene.sources.forEach((source, sourceIndex) => {
|
|
if (source.signal.type === "tone") return;
|
|
const transient = sampledSignalTransient(
|
|
dimensions.sampleCount,
|
|
acousticPathSummary.sources[sourceIndex].maximumDelaySeconds,
|
|
scene.sampleRate,
|
|
);
|
|
const entry = { scope: "source", id: source.id, ...transient };
|
|
sampledSources.push(entry);
|
|
if (entry.totalBytes > maximumTransientBytes) {
|
|
maximumTransientBytes = entry.totalBytes;
|
|
maximumTransient = entry;
|
|
}
|
|
});
|
|
const sampledAmbient = [];
|
|
for (const ambient of scene.ambient ?? []) {
|
|
if (ambient.signal.type === "tone") continue;
|
|
const transient = sampledSignalTransient(
|
|
dimensions.sampleCount,
|
|
0,
|
|
scene.sampleRate,
|
|
);
|
|
const entry = { scope: "ambient", id: ambient.id, ...transient };
|
|
sampledAmbient.push(entry);
|
|
if (entry.totalBytes > maximumTransientBytes) {
|
|
maximumTransientBytes = entry.totalBytes;
|
|
maximumTransient = entry;
|
|
}
|
|
}
|
|
const estimatedBufferBytes = channelBytes + maximumTransientBytes;
|
|
if (!Number.isSafeInteger(estimatedBufferBytes)) {
|
|
throw new RangeError("estimated synthesis typed working set exceeds the safe integer range.");
|
|
}
|
|
return {
|
|
...dimensions,
|
|
estimatedBufferBytes,
|
|
channelBytes,
|
|
maximumTransientBytes,
|
|
maximumTransient,
|
|
sampledSources,
|
|
sampledAmbient,
|
|
acousticPathSummary,
|
|
activePathMaximumDelaySamples,
|
|
reusedPathSummary,
|
|
model: "crop-channels-plus-one-sampled-signal-spectrum-and-workspace",
|
|
};
|
|
}
|
|
|
|
function assertWorkingSetWithinLimit(estimatedBytes, maximumBufferBytes) {
|
|
if (
|
|
!(maximumBufferBytes > 0) ||
|
|
!Number.isFinite(maximumBufferBytes)
|
|
) {
|
|
throw new RangeError("maximumBufferBytes must be finite and positive.");
|
|
}
|
|
if (estimatedBytes > maximumBufferBytes) {
|
|
throw new Error(
|
|
`예상 signal buffer ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
|
`현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function createSourceArrivalState(options) {
|
|
const {
|
|
definition,
|
|
scene,
|
|
assetBuffers,
|
|
timeMapping,
|
|
} = options;
|
|
const emissionStartTime =
|
|
timeMapping.signalStartTime ?? definition.startTime ?? 0;
|
|
const declaredDuration =
|
|
timeMapping.signalDuration ?? definition.duration ?? scene.duration;
|
|
const emissionDuration = effectiveSignalDurationSeconds(
|
|
{ ...definition, duration: declaredDuration },
|
|
scene,
|
|
assetBuffers,
|
|
);
|
|
return {
|
|
sourceId: definition.id,
|
|
emissionStartTime,
|
|
emissionDuration,
|
|
earliestArrivalSample: Infinity,
|
|
latestArrivalEndSample: -Infinity,
|
|
overlapsOutput: false,
|
|
overlappingPathKinds: new Set(),
|
|
};
|
|
}
|
|
|
|
function observeSourceArrivalPaths(
|
|
state,
|
|
paths,
|
|
sampleRate,
|
|
outputStartSample,
|
|
outputEndSample,
|
|
) {
|
|
for (const path of paths) {
|
|
const arrivalStartSample = ceilSampleIndex(
|
|
state.emissionStartTime + path.delaySeconds,
|
|
sampleRate,
|
|
);
|
|
const arrivalEndSample = ceilSampleIndex(
|
|
state.emissionStartTime + state.emissionDuration + path.delaySeconds,
|
|
sampleRate,
|
|
);
|
|
state.earliestArrivalSample = Math.min(
|
|
state.earliestArrivalSample,
|
|
arrivalStartSample,
|
|
);
|
|
state.latestArrivalEndSample = Math.max(
|
|
state.latestArrivalEndSample,
|
|
arrivalEndSample,
|
|
);
|
|
if (
|
|
arrivalStartSample < outputEndSample &&
|
|
arrivalEndSample > outputStartSample
|
|
) {
|
|
state.overlapsOutput = true;
|
|
state.overlappingPathKinds.add(path.kind);
|
|
}
|
|
}
|
|
}
|
|
|
|
function finalizeSourceArrival(state, sampleRate) {
|
|
return {
|
|
sourceId: state.sourceId,
|
|
emissionStartSample: ceilSampleIndex(state.emissionStartTime, sampleRate),
|
|
emissionEndSample: ceilSampleIndex(
|
|
state.emissionStartTime + state.emissionDuration,
|
|
sampleRate,
|
|
),
|
|
earliestArrivalSample: Number.isFinite(state.earliestArrivalSample)
|
|
? state.earliestArrivalSample
|
|
: null,
|
|
latestArrivalEndSample: Number.isFinite(state.latestArrivalEndSample)
|
|
? state.latestArrivalEndSample
|
|
: null,
|
|
overlapsOutput: state.overlapsOutput,
|
|
overlappingPathKinds: [...state.overlappingPathKinds].sort(),
|
|
};
|
|
}
|
|
|
|
export function synthesizeMicrophoneSignals(options) {
|
|
const {
|
|
scene,
|
|
arrayDefinition,
|
|
assetBuffers = new Map(),
|
|
referenceDistance = 1,
|
|
minimumDistance = 0.01,
|
|
maximumBufferBytes = DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
|
|
maximumPathEvaluations = DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
|
signalOffsets,
|
|
} = options;
|
|
const workingSet = estimateSynthesisWorkingSet({
|
|
scene,
|
|
arrayDefinition,
|
|
referenceDistance,
|
|
minimumDistance,
|
|
signalOffsets,
|
|
acousticPathSummary: options.acousticPathSummary,
|
|
maximumPathEvaluations,
|
|
});
|
|
assertWorkingSetWithinLimit(
|
|
workingSet.estimatedBufferBytes,
|
|
maximumBufferBytes,
|
|
);
|
|
const {
|
|
sampleCount,
|
|
sceneSampleOffset,
|
|
cropStartSample,
|
|
cropSampleCount,
|
|
outputStartSample,
|
|
acousticPathSummary,
|
|
} = workingSet;
|
|
const outputEndSample = outputStartSample + cropSampleCount;
|
|
const needsOutputCrop =
|
|
cropStartSample !== 0 || cropSampleCount !== sampleCount;
|
|
const synthesisChannels = arrayDefinition.microphones.map(
|
|
() => new Float32Array(cropSampleCount),
|
|
);
|
|
const sourceArrivals = [];
|
|
|
|
scene.sources.forEach((definition, sourceIndex) => {
|
|
const timeMapping = {
|
|
sceneSampleOffset,
|
|
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
|
|
};
|
|
const arrivalState = createSourceArrivalState({
|
|
definition,
|
|
scene,
|
|
assetBuffers,
|
|
timeMapping,
|
|
});
|
|
const sourceWorkingSet = workingSet.sampledSources.find(
|
|
({ id }) => id === definition.id,
|
|
);
|
|
if (sourceWorkingSet) {
|
|
assertWorkingSetWithinLimit(
|
|
workingSet.channelBytes + sourceWorkingSet.totalBytes,
|
|
maximumBufferBytes,
|
|
);
|
|
}
|
|
const samples = definition.signal.type === "tone"
|
|
? undefined
|
|
: renderSignal(definition, scene, assetBuffers, 0, timeMapping);
|
|
const spectrum = samples
|
|
? prepareDelaySpectrum(
|
|
samples,
|
|
sourceWorkingSet.maximumDelaySamples,
|
|
)
|
|
: undefined;
|
|
const workspace = spectrum
|
|
? createDelayWorkspace(spectrum.fftSize)
|
|
: undefined;
|
|
|
|
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
|
|
const paths = buildAcousticPaths({
|
|
scene,
|
|
sourcePosition: definition.position,
|
|
receiverPosition: microphone.position,
|
|
sourceId: definition.id,
|
|
receiverId: microphone.id,
|
|
referenceDistance,
|
|
minimumDistance,
|
|
});
|
|
observeSourceArrivalPaths(
|
|
arrivalState,
|
|
paths,
|
|
scene.sampleRate,
|
|
outputStartSample,
|
|
outputEndSample,
|
|
);
|
|
if (definition.signal.type === "tone") {
|
|
addSampledAnalogTonePaths({
|
|
output: synthesisChannels[microphoneIndex],
|
|
entry: definition,
|
|
scene,
|
|
microphone,
|
|
paths,
|
|
timeMapping,
|
|
outputSceneSampleOffset: outputStartSample,
|
|
outputRenderSampleOffset: cropStartSample,
|
|
});
|
|
} else {
|
|
addFrequencyDomainPaths({
|
|
output: synthesisChannels[microphoneIndex],
|
|
spectrum,
|
|
paths,
|
|
pathDelaySampleRate: scene.sampleRate,
|
|
gain: dbToLinear(microphone.sensitivityDb ?? 0),
|
|
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
|
frequencyResponse: microphone.frequencyResponse,
|
|
sampleRate: scene.sampleRate,
|
|
workspace,
|
|
outputOffsetSamples: cropStartSample,
|
|
});
|
|
}
|
|
});
|
|
sourceArrivals.push(finalizeSourceArrival(arrivalState, scene.sampleRate));
|
|
});
|
|
|
|
if (Array.isArray(scene.ambient)) {
|
|
scene.ambient.forEach((ambient, ambientIndex) => {
|
|
const ambientTimeMapping = {
|
|
sceneSampleOffset,
|
|
...(signalOffsets?.ambient?.[ambientIndex] ?? {}),
|
|
};
|
|
const ambientWorkingSet = workingSet.sampledAmbient.find(
|
|
({ id }) => id === ambient.id,
|
|
);
|
|
if (ambientWorkingSet) {
|
|
assertWorkingSetWithinLimit(
|
|
workingSet.channelBytes + ambientWorkingSet.totalBytes,
|
|
maximumBufferBytes,
|
|
);
|
|
}
|
|
const ambientWorkspace = ambientWorkingSet
|
|
? createDelayWorkspace(ambientWorkingSet.fftSize)
|
|
: undefined;
|
|
synthesisChannels.forEach((channel, microphoneIndex) => {
|
|
const microphone = arrayDefinition.microphones[microphoneIndex];
|
|
if (ambient.signal.type === "tone") {
|
|
addSampledAnalogTonePaths({
|
|
output: channel,
|
|
entry: ambient,
|
|
scene,
|
|
microphone,
|
|
paths: [{ delaySeconds: 0, gain: 1 }],
|
|
timeMapping: ambientTimeMapping,
|
|
outputSceneSampleOffset: outputStartSample,
|
|
outputRenderSampleOffset: cropStartSample,
|
|
});
|
|
return;
|
|
}
|
|
const seedSalt =
|
|
Math.imul(ambientIndex + 1, 0x45d9f3b) ^
|
|
Math.imul(microphoneIndex + 1, 0x27d4eb2d);
|
|
const ambientSamples = renderSignal(
|
|
ambient,
|
|
scene,
|
|
assetBuffers,
|
|
seedSalt,
|
|
ambientTimeMapping,
|
|
);
|
|
const spectrum = prepareDelaySpectrum(ambientSamples, 0);
|
|
addFrequencyDomainDelay({
|
|
output: channel,
|
|
spectrum,
|
|
delaySamples: 0,
|
|
gain: dbToLinear(microphone.sensitivityDb ?? 0),
|
|
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
|
frequencyResponse: microphone.frequencyResponse,
|
|
sampleRate: scene.sampleRate,
|
|
workspace: ambientWorkspace,
|
|
outputOffsetSamples: cropStartSample,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
const channels = synthesisChannels;
|
|
|
|
let preNormalizationPeak = 0;
|
|
for (const channel of channels) {
|
|
for (const value of channel) {
|
|
preNormalizationPeak = Math.max(preNormalizationPeak, Math.abs(value));
|
|
}
|
|
}
|
|
const appliedScale =
|
|
scene.mix?.normalization === "peak" && preNormalizationPeak > 0
|
|
? scene.mix.targetPeak / preNormalizationPeak
|
|
: 1;
|
|
let peak = 0;
|
|
let clippedSampleCount = 0;
|
|
for (const channel of channels) {
|
|
for (let sampleIndex = 0; sampleIndex < channel.length; sampleIndex += 1) {
|
|
channel[sampleIndex] *= appliedScale;
|
|
const magnitude = Math.abs(channel[sampleIndex]);
|
|
peak = Math.max(peak, magnitude);
|
|
if (magnitude > 1) clippedSampleCount += 1;
|
|
}
|
|
}
|
|
return {
|
|
channels,
|
|
sampleRate: scene.sampleRate,
|
|
peak,
|
|
preNormalizationPeak,
|
|
appliedScale,
|
|
clippedSampleCount,
|
|
estimatedBufferBytes: workingSet.estimatedBufferBytes,
|
|
workingSet: {
|
|
model: workingSet.model,
|
|
channelBytes: workingSet.channelBytes,
|
|
maximumTransientBytes: workingSet.maximumTransientBytes,
|
|
maximumTransientScope: workingSet.maximumTransient.scope,
|
|
maximumTransientId: workingSet.maximumTransient.id,
|
|
pathSummary: workingSet.reusedPathSummary
|
|
? "reused-authenticated-geometry-summary"
|
|
: "computed-for-synthesis",
|
|
summaryPathPairEvaluations: workingSet.reusedPathSummary
|
|
? 0
|
|
: acousticPathSummary.sourceReceiverPairCount,
|
|
synthesisPathBuildPairEvaluations:
|
|
acousticPathSummary.sourceReceiverPairCount,
|
|
retainedPathObjects: "one-source-receiver-pair",
|
|
remainingNonToneTransformModel: "one-ifft-per-source-and-microphone",
|
|
},
|
|
acousticModel: scene.acoustics?.model ?? "free-field",
|
|
acousticPathCount: acousticPathSummary.pathCount,
|
|
acousticPathCounts: { ...acousticPathSummary.pathCounts },
|
|
windowing: {
|
|
model: signalOffsets?.windowingModel ?? "whole-scene",
|
|
renderStartSample: sceneSampleOffset,
|
|
outputStartSample,
|
|
outputSampleCount: cropSampleCount,
|
|
preRollSamples: cropStartSample,
|
|
renderMaximumPropagationDelaySeconds:
|
|
signalOffsets?.renderMaximumPropagationDelaySeconds ??
|
|
acousticPathSummary.maximumDelaySeconds,
|
|
activePathMaximumDelaySeconds: acousticPathSummary.maximumDelaySeconds,
|
|
requestedMaximumPropagationDelaySeconds:
|
|
signalOffsets?.maximumPropagationDelaySeconds ??
|
|
acousticPathSummary.maximumDelaySeconds,
|
|
geometryModel: Number.isFinite(signalOffsets?.sourcePositionSampleTime)
|
|
? "source-position-fixed-at-window-midpoint"
|
|
: "static-source-position",
|
|
sourcePositionSampleTime: signalOffsets?.sourcePositionSampleTime ?? null,
|
|
dopplerMode: "not-modeled",
|
|
delayedTailPolicy: needsOutputCrop
|
|
? "propagation-preroll-then-output-crop"
|
|
: signalOffsets?.windowingModel
|
|
? "live-request-window-direct-output-with-analytic-path-gating"
|
|
: "whole-render-buffer",
|
|
arrivalOverlapModel: "path-gated-half-open-sample-interval",
|
|
arrivingSourceIds: sourceArrivals
|
|
.filter(({ overlapsOutput }) => overlapsOutput)
|
|
.map(({ sourceId }) => sourceId),
|
|
sourceArrivals,
|
|
},
|
|
sampling: {
|
|
model: "ideal-real-sampler-no-antialias-filter",
|
|
sampleRate: scene.sampleRate,
|
|
nyquistHz: scene.sampleRate / 2,
|
|
toneObservations: [
|
|
...scene.sources
|
|
.filter((source) => source.signal.type === "tone")
|
|
.map((source) => ({
|
|
scope: "source",
|
|
id: source.id,
|
|
...describeFrequencyAliasing(source.signal.frequency, scene.sampleRate),
|
|
})),
|
|
...(scene.ambient ?? [])
|
|
.filter((ambient) => ambient.signal.type === "tone")
|
|
.map((ambient) => ({
|
|
scope: "ambient",
|
|
id: ambient.id,
|
|
...describeFrequencyAliasing(ambient.signal.frequency, scene.sampleRate),
|
|
})),
|
|
],
|
|
},
|
|
};
|
|
}
|