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), })), ], }, }; }