Files

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