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 } from "./environment.js"; import { 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 = {}, } = options; const signal = entry.signal; const frequencyHz = signal.frequency; const sampling = describeFrequencyAliasing(frequencyHz, scene.sampleRate); const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0; 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.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) - sceneSampleOffset, ); const lastSample = Math.min( output.length, ceilSampleIndex( signalStartTime + signalDuration + path.delaySeconds, scene.sampleRate, ) - sceneSampleOffset, ); 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, sceneSampleOffset + 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, sceneSampleOffset + sampleIndex, ); carrierCosine = carrier.cosine; carrierSine = carrier.sine; continue; } output[sampleIndex] += carrierSine * sineCoefficient + carrierCosine * cosineCoefficient; const nextSample = sampleIndex + 1; if (nextSample % phaseReanchorInterval === 0) { const carrier = carrierAtSample( sampling.signedAdcFrequencyHz, scene.sampleRate, sceneSampleOffset + nextSample, ); carrierCosine = carrier.cosine; carrierSine = carrier.sine; } else { const nextCosine = carrierCosine * stepReal - carrierSine * stepImaginary; carrierSine = carrierSine * stepReal + carrierCosine * stepImaginary; carrierCosine = nextCosine; } sampleIndex = nextSample; } } export function synthesizeMicrophoneSignals(options) { const { scene, arrayDefinition, assetBuffers = new Map(), referenceDistance = 1, minimumDistance = 0.01, maximumBufferBytes = 512 * 1024 * 1024, signalOffsets, } = options; 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 cropEndSample = cropStartSample + cropSampleCount; const needsOutputCrop = cropStartSample !== 0 || cropSampleCount !== sampleCount; const sourcePathSets = scene.sources.map((definition) => ({ definition, microphonePaths: arrayDefinition.microphones.map((microphone) => buildAcousticPaths({ scene, sourcePosition: definition.position, receiverPosition: microphone.position, sourceId: definition.id, receiverId: microphone.id, referenceDistance, minimumDistance, }), ), })); const acousticPathCounts = {}; let acousticPathCount = 0; let maximumDelaySamples = 0; for (const { microphonePaths } of sourcePathSets) { for (const paths of microphonePaths) { for (const path of paths) { acousticPathCount += 1; acousticPathCounts[path.kind] = (acousticPathCounts[path.kind] ?? 0) + 1; maximumDelaySamples = Math.max( maximumDelaySamples, path.delaySeconds * scene.sampleRate, ); } } } const sampledSourceCount = scene.sources .filter((source) => source.signal.type !== "tone") .length; const hasSampledAmbient = (scene.ambient ?? []) .some((ambient) => ambient.signal.type !== "tone"); const retainedBufferCount = arrayDefinition.microphones.length + sampledSourceCount + (hasSampledAmbient ? 1 : 0); const delayFftSize = nextPowerOfTwo( sampleCount + ceilSamplePosition(maximumDelaySamples) + 2, ); const delayWorkingBytes = sampledSourceCount > 0 || hasSampledAmbient ? delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4 : 0; const outputCropBytes = needsOutputCrop ? arrayDefinition.microphones.length * cropSampleCount * Float32Array.BYTES_PER_ELEMENT : 0; const estimatedBufferBytes = retainedBufferCount * sampleCount * Float32Array.BYTES_PER_ELEMENT + delayWorkingBytes + outputCropBytes; if (estimatedBufferBytes > maximumBufferBytes) { throw new Error( `예상 signal buffer ${(estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 ` + `현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`, ); } const synthesisChannels = arrayDefinition.microphones.map( () => new Float32Array(sampleCount), ); const renderedSources = sourcePathSets.map(({ definition, microphonePaths }, sourceIndex) => ({ definition, microphonePaths, timeMapping: { sceneSampleOffset, ...(signalOffsets?.sources?.[sourceIndex] ?? {}), }, ...(definition.signal.type === "tone" ? {} : { samples: renderSignal(definition, scene, assetBuffers, 0, { sceneSampleOffset, ...(signalOffsets?.sources?.[sourceIndex] ?? {}), }), }), })); const outputStartSample = sceneSampleOffset + cropStartSample; const outputEndSample = outputStartSample + cropSampleCount; const sourceArrivals = renderedSources.map(({ definition, microphonePaths, timeMapping }) => { const emissionStartTime = timeMapping.signalStartTime ?? definition.startTime ?? 0; const emissionDuration = timeMapping.signalDuration ?? definition.duration ?? scene.duration; let earliestArrivalSample = Infinity; let latestArrivalEndSample = -Infinity; let overlapsOutput = false; const overlappingPathKinds = new Set(); for (const paths of microphonePaths) { for (const path of paths) { const arrivalStartSample = ceilSampleIndex( emissionStartTime + path.delaySeconds, scene.sampleRate, ); const arrivalEndSample = ceilSampleIndex( emissionStartTime + emissionDuration + path.delaySeconds, scene.sampleRate, ); earliestArrivalSample = Math.min(earliestArrivalSample, arrivalStartSample); latestArrivalEndSample = Math.max(latestArrivalEndSample, arrivalEndSample); if (arrivalStartSample < outputEndSample && arrivalEndSample > outputStartSample) { overlapsOutput = true; overlappingPathKinds.add(path.kind); } } } return { sourceId: definition.id, emissionStartSample: ceilSampleIndex(emissionStartTime, scene.sampleRate), emissionEndSample: ceilSampleIndex( emissionStartTime + emissionDuration, scene.sampleRate, ), earliestArrivalSample: Number.isFinite(earliestArrivalSample) ? earliestArrivalSample : null, latestArrivalEndSample: Number.isFinite(latestArrivalEndSample) ? latestArrivalEndSample : null, overlapsOutput, overlappingPathKinds: [...overlappingPathKinds].sort(), }; }); renderedSources.forEach(({ definition, microphonePaths, samples, timeMapping }) => { let sourceMaximumDelaySamples = 0; for (const paths of microphonePaths) { for (const path of paths) { sourceMaximumDelaySamples = Math.max( sourceMaximumDelaySamples, path.delaySeconds * scene.sampleRate, ); } } const spectrum = samples ? prepareDelaySpectrum(samples, sourceMaximumDelaySamples) : undefined; const workspace = spectrum ? createDelayWorkspace(spectrum.fftSize) : undefined; arrayDefinition.microphones.forEach((microphone, microphoneIndex) => { const paths = microphonePaths[microphoneIndex]; const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0); if (definition.signal.type === "tone") { addSampledAnalogTonePaths({ output: synthesisChannels[microphoneIndex], entry: definition, scene, microphone, paths, timeMapping, }); } else { addFrequencyDomainPaths({ output: synthesisChannels[microphoneIndex], spectrum, paths: paths.map((path) => ({ delaySamples: path.delaySeconds * scene.sampleRate, gain: path.gain, })), gain: sensitivity, phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0, frequencyResponse: microphone.frequencyResponse, sampleRate: scene.sampleRate, workspace, }); } }); }); if (Array.isArray(scene.ambient)) { scene.ambient.forEach((ambient, ambientIndex) => { synthesisChannels.forEach((channel, microphoneIndex) => { const microphone = arrayDefinition.microphones[microphoneIndex]; const ambientTimeMapping = { sceneSampleOffset, ...(signalOffsets?.ambient?.[ambientIndex] ?? {}), }; if (ambient.signal.type === "tone") { addSampledAnalogTonePaths({ output: channel, entry: ambient, scene, microphone, paths: [{ delaySeconds: 0, gain: 1 }], timeMapping: ambientTimeMapping, }); 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: createDelayWorkspace(spectrum.fftSize), }); }); }); } const channels = needsOutputCrop ? synthesisChannels.map((channel) => channel.slice(cropStartSample, cropEndSample)) : 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, acousticModel: scene.acoustics?.model ?? "free-field", acousticPathCount, acousticPathCounts, windowing: { model: signalOffsets?.windowingModel ?? "whole-scene", renderStartSample: sceneSampleOffset, outputStartSample, outputSampleCount: cropSampleCount, preRollSamples: cropStartSample, activePathMaximumDelaySeconds: maximumDelaySamples / scene.sampleRate, requestedMaximumPropagationDelaySeconds: signalOffsets?.maximumPropagationDelaySeconds ?? maximumDelaySamples / scene.sampleRate, 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" : "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), })), ], }, }; }