feat: add physical 3d receive pattern core
This commit is contained in:
@@ -9,6 +9,11 @@ export const MAXIMUM_RECEIVE_BEAM_PATTERN_MICROPHONES = 1_024;
|
||||
export const MAXIMUM_RECEIVE_BEAM_PATTERN_FREQUENCY_HZ = 1_000_000;
|
||||
export const MAXIMUM_RECEIVE_BEAM_PATTERN_BASELINE_COMPARISONS = 600_000;
|
||||
export const RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD = Math.PI / 8;
|
||||
export const DEFAULT_RECEIVE_BEAM_PATTERN_SPHERE_AZIMUTH_SAMPLES = 360;
|
||||
export const DEFAULT_RECEIVE_BEAM_PATTERN_SPHERE_ELEVATION_SAMPLES = 181;
|
||||
export const MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_AZIMUTH_SAMPLES = 1_440;
|
||||
export const MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_ELEVATION_SAMPLES = 721;
|
||||
export const MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_SAMPLES = 262_144;
|
||||
|
||||
const HALF_POWER = 0.5;
|
||||
const RADIANS_PER_DEGREE = Math.PI / 180;
|
||||
@@ -82,6 +87,29 @@ export function receiveBeamPatternDirection(cut, angleDeg) {
|
||||
return [0, sine, cosine];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert standard spherical azimuth/elevation to an array-to-source unit
|
||||
* vector. Azimuth is measured in the XY plane from +X toward +Y. Elevation is
|
||||
* measured from the XY plane toward +Z, with +90 degrees at the north pole.
|
||||
*/
|
||||
export function receiveBeamPatternSphereDirection(azimuthDeg, elevationDeg) {
|
||||
const azimuthRadians = normalizeBeamPatternAngle(azimuthDeg) *
|
||||
RADIANS_PER_DEGREE;
|
||||
const elevation = requireFinite(elevationDeg, "elevationDeg");
|
||||
if (elevation < -90 || elevation > 90) {
|
||||
throw new RangeError("elevationDeg must lie inside -90..90 degrees.");
|
||||
}
|
||||
if (elevation === 90) return [0, 0, 1];
|
||||
if (elevation === -90) return [0, 0, -1];
|
||||
const elevationRadians = elevation * RADIANS_PER_DEGREE;
|
||||
const cosineElevation = Math.cos(elevationRadians);
|
||||
return [
|
||||
cosineElevation * Math.cos(azimuthRadians),
|
||||
cosineElevation * Math.sin(azimuthRadians),
|
||||
Math.sin(elevationRadians),
|
||||
];
|
||||
}
|
||||
|
||||
function cutCoordinates(position, cut) {
|
||||
if (cut === "xy") return [position[0], position[1]];
|
||||
if (cut === "xz") return [position[2], position[0]];
|
||||
@@ -289,6 +317,32 @@ function maximumProjectedBaseline(coordinates) {
|
||||
return maximum;
|
||||
}
|
||||
|
||||
function maximumSpatialBaseline(positions) {
|
||||
let maximum = 0;
|
||||
for (let left = 0; left < positions.length; left += 1) {
|
||||
for (let right = left + 1; right < positions.length; right += 1) {
|
||||
maximum = Math.max(
|
||||
maximum,
|
||||
Math.hypot(
|
||||
positions[left][0] - positions[right][0],
|
||||
positions[left][1] - positions[right][1],
|
||||
positions[left][2] - positions[right][2],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return maximum;
|
||||
}
|
||||
|
||||
function requireSphereSampleCount(value, label, minimum, maximum) {
|
||||
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(
|
||||
label + " must be an integer inside " + minimum + ".." + maximum + ".",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function recommendedSampleCount(waveNumber, projectedApertureM) {
|
||||
if (projectedApertureM === 0) return 36;
|
||||
const ratio = RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD /
|
||||
@@ -623,3 +677,356 @@ export function computeReceiveBeamPattern(options) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample the same ideal conventional receive array factor over a full sphere.
|
||||
*
|
||||
* The returned rectangular grid is equirectangular: azimuth samples span
|
||||
* [0, 360) without a duplicate endpoint, while elevation samples include both
|
||||
* poles. Pole rows therefore contain coincident directions for every azimuth.
|
||||
* All response arrays are raw elevation-major samples; no interpolation,
|
||||
* smoothing, frequency folding, or sampled-maximum normalization is applied.
|
||||
*/
|
||||
export function computeReceiveBeamPatternSphere(options) {
|
||||
const array = options?.array;
|
||||
const positions = requireMicrophones(array);
|
||||
const frequencyHz = requirePositive(options?.frequencyHz, "frequencyHz");
|
||||
if (frequencyHz > MAXIMUM_RECEIVE_BEAM_PATTERN_FREQUENCY_HZ) {
|
||||
throw new RangeError(
|
||||
"frequencyHz must not exceed " +
|
||||
MAXIMUM_RECEIVE_BEAM_PATTERN_FREQUENCY_HZ + " Hz.",
|
||||
);
|
||||
}
|
||||
const speedOfSound = requirePositive(options?.speedOfSound ?? 343, "speedOfSound");
|
||||
const steeringAzimuthDeg = normalizeBeamPatternAngle(
|
||||
options?.steeringAzimuthDeg ?? 0,
|
||||
);
|
||||
const steeringElevationDeg = requireFinite(
|
||||
options?.steeringElevationDeg ?? 90,
|
||||
"steeringElevationDeg",
|
||||
);
|
||||
if (steeringElevationDeg < -90 || steeringElevationDeg > 90) {
|
||||
throw new RangeError(
|
||||
"steeringElevationDeg must lie inside -90..90 degrees.",
|
||||
);
|
||||
}
|
||||
const azimuthSampleCount = requireSphereSampleCount(
|
||||
options?.azimuthSampleCount ??
|
||||
DEFAULT_RECEIVE_BEAM_PATTERN_SPHERE_AZIMUTH_SAMPLES,
|
||||
"azimuthSampleCount",
|
||||
4,
|
||||
MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_AZIMUTH_SAMPLES,
|
||||
);
|
||||
const elevationSampleCount = requireSphereSampleCount(
|
||||
options?.elevationSampleCount ??
|
||||
DEFAULT_RECEIVE_BEAM_PATTERN_SPHERE_ELEVATION_SAMPLES,
|
||||
"elevationSampleCount",
|
||||
3,
|
||||
MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_ELEVATION_SAMPLES,
|
||||
);
|
||||
const sampleCount = azimuthSampleCount * elevationSampleCount;
|
||||
if (
|
||||
!Number.isSafeInteger(sampleCount) ||
|
||||
sampleCount > MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_SAMPLES
|
||||
) {
|
||||
throw new RangeError(
|
||||
"sphere sample product exceeds the receive beam-pattern limit of " +
|
||||
MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_SAMPLES + ".",
|
||||
);
|
||||
}
|
||||
const dbFloor = requireFinite(
|
||||
options?.dbFloor ?? DEFAULT_RECEIVE_BEAM_PATTERN_DB_FLOOR,
|
||||
"dbFloor",
|
||||
);
|
||||
if (dbFloor < -300 || dbFloor > -1) {
|
||||
throw new RangeError("dbFloor must lie inside -300..-1 dB.");
|
||||
}
|
||||
const maximumEvaluations = options?.maximumEvaluations ??
|
||||
MAXIMUM_RECEIVE_BEAM_PATTERN_EVALUATIONS;
|
||||
if (!Number.isSafeInteger(maximumEvaluations) || maximumEvaluations < 1) {
|
||||
throw new RangeError("maximumEvaluations must be a positive safe integer.");
|
||||
}
|
||||
const microphoneCount = positions.length;
|
||||
const sampleEvaluationCount = microphoneCount * sampleCount;
|
||||
if (
|
||||
!Number.isSafeInteger(sampleEvaluationCount) ||
|
||||
sampleEvaluationCount > maximumEvaluations
|
||||
) {
|
||||
throw new RangeError(
|
||||
"receive beam-pattern sphere requires " + sampleEvaluationCount +
|
||||
" microphone-direction evaluations; limit is " +
|
||||
maximumEvaluations + ".",
|
||||
);
|
||||
}
|
||||
const maximumBaselineComparisons = options?.maximumBaselineComparisons ??
|
||||
MAXIMUM_RECEIVE_BEAM_PATTERN_BASELINE_COMPARISONS;
|
||||
if (
|
||||
!Number.isSafeInteger(maximumBaselineComparisons) ||
|
||||
maximumBaselineComparisons < 0
|
||||
) {
|
||||
throw new RangeError(
|
||||
"maximumBaselineComparisons must be a non-negative safe integer.",
|
||||
);
|
||||
}
|
||||
const spatialBaselineComparisons =
|
||||
microphoneCount * (microphoneCount - 1) / 2;
|
||||
if (spatialBaselineComparisons > maximumBaselineComparisons) {
|
||||
throw new RangeError(
|
||||
"receive beam-pattern sphere requires " + spatialBaselineComparisons +
|
||||
" spatial-baseline comparisons; limit is " +
|
||||
maximumBaselineComparisons + ".",
|
||||
);
|
||||
}
|
||||
|
||||
const centroid = [0, 0, 0];
|
||||
for (const position of positions) {
|
||||
centroid[0] += position[0] / microphoneCount;
|
||||
centroid[1] += position[1] / microphoneCount;
|
||||
centroid[2] += position[2] / microphoneCount;
|
||||
}
|
||||
const centeredPositions = positions.map((position) => [
|
||||
position[0] - centroid[0],
|
||||
position[1] - centroid[1],
|
||||
position[2] - centroid[2],
|
||||
]);
|
||||
const spatialApertureM = maximumSpatialBaseline(centeredPositions);
|
||||
const waveNumber = TAU * frequencyHz / speedOfSound;
|
||||
const maximumPhaseMagnitudeRadBound = 2 * waveNumber * spatialApertureM;
|
||||
if (
|
||||
!Number.isFinite(waveNumber) ||
|
||||
!Number.isFinite(maximumPhaseMagnitudeRadBound)
|
||||
) {
|
||||
throw new RangeError(
|
||||
"frequency, speed of sound, and array coordinates overflow phase.",
|
||||
);
|
||||
}
|
||||
|
||||
const azimuthStepDeg = 360 / azimuthSampleCount;
|
||||
const elevationStepDeg = 180 / (elevationSampleCount - 1);
|
||||
const azimuthStepRad = TAU / azimuthSampleCount;
|
||||
const elevationStepRad = Math.PI / (elevationSampleCount - 1);
|
||||
const azimuthAdjacentDirectionChordBound =
|
||||
2 * Math.sin(azimuthStepRad / 2);
|
||||
const elevationAdjacentDirectionChordBound =
|
||||
2 * Math.sin(elevationStepRad / 2);
|
||||
const maximumCellDiagonalDirectionChordBound = 2 * Math.hypot(
|
||||
Math.sin(elevationStepRad / 2),
|
||||
Math.cos(elevationStepRad / 2) * Math.sin(azimuthStepRad / 2),
|
||||
);
|
||||
const maximumAzimuthAdjacentPhaseStepRadBound =
|
||||
waveNumber * spatialApertureM * azimuthAdjacentDirectionChordBound;
|
||||
const maximumElevationAdjacentPhaseStepRadBound =
|
||||
waveNumber * spatialApertureM * elevationAdjacentDirectionChordBound;
|
||||
const maximumCellDiagonalPhaseStepRadBound =
|
||||
waveNumber * spatialApertureM *
|
||||
maximumCellDiagonalDirectionChordBound;
|
||||
const resolutionCriterionSatisfied =
|
||||
maximumCellDiagonalPhaseStepRadBound <=
|
||||
RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD *
|
||||
(1 + 64 * Number.EPSILON);
|
||||
|
||||
const azimuthAxisDeg = new Float64Array(azimuthSampleCount);
|
||||
const elevationAxisDeg = new Float64Array(elevationSampleCount);
|
||||
const azimuthCosines = new Float64Array(azimuthSampleCount);
|
||||
const azimuthSines = new Float64Array(azimuthSampleCount);
|
||||
const elevationCosines = new Float64Array(elevationSampleCount);
|
||||
const elevationSines = new Float64Array(elevationSampleCount);
|
||||
for (let azimuthIndex = 0; azimuthIndex < azimuthSampleCount; azimuthIndex += 1) {
|
||||
const azimuthDeg = azimuthIndex * azimuthStepDeg;
|
||||
const azimuthRadians = azimuthIndex * azimuthStepRad;
|
||||
azimuthAxisDeg[azimuthIndex] = azimuthDeg;
|
||||
azimuthCosines[azimuthIndex] = Math.cos(azimuthRadians);
|
||||
azimuthSines[azimuthIndex] = Math.sin(azimuthRadians);
|
||||
}
|
||||
for (
|
||||
let elevationIndex = 0;
|
||||
elevationIndex < elevationSampleCount;
|
||||
elevationIndex += 1
|
||||
) {
|
||||
const elevationDeg = elevationIndex === elevationSampleCount - 1
|
||||
? 90
|
||||
: -90 + elevationIndex * elevationStepDeg;
|
||||
const elevationRadians = elevationDeg * RADIANS_PER_DEGREE;
|
||||
elevationAxisDeg[elevationIndex] = elevationDeg;
|
||||
if (elevationIndex === 0 || elevationIndex === elevationSampleCount - 1) {
|
||||
elevationCosines[elevationIndex] = 0;
|
||||
elevationSines[elevationIndex] = elevationIndex === 0 ? -1 : 1;
|
||||
} else {
|
||||
elevationCosines[elevationIndex] = Math.cos(elevationRadians);
|
||||
elevationSines[elevationIndex] = Math.sin(elevationRadians);
|
||||
}
|
||||
}
|
||||
|
||||
const steeringDirection = receiveBeamPatternSphereDirection(
|
||||
steeringAzimuthDeg,
|
||||
steeringElevationDeg,
|
||||
);
|
||||
const steeringProjections = new Float64Array(microphoneCount);
|
||||
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
|
||||
const position = centeredPositions[microphoneIndex];
|
||||
steeringProjections[microphoneIndex] =
|
||||
position[0] * steeringDirection[0] +
|
||||
position[1] * steeringDirection[1] +
|
||||
position[2] * steeringDirection[2];
|
||||
}
|
||||
|
||||
const responseReal = new Float64Array(sampleCount);
|
||||
const responseImaginary = new Float64Array(sampleCount);
|
||||
const responsePower = new Float64Array(sampleCount);
|
||||
const responseLinear = new Float64Array(sampleCount);
|
||||
const responseDb = new Float64Array(sampleCount);
|
||||
const displayDb = new Float64Array(sampleCount);
|
||||
let sampledMaximumLinear = 0;
|
||||
let sampledMaximumPower = 0;
|
||||
for (
|
||||
let elevationIndex = 0;
|
||||
elevationIndex < elevationSampleCount;
|
||||
elevationIndex += 1
|
||||
) {
|
||||
const cosineElevation = elevationCosines[elevationIndex];
|
||||
const directionZ = elevationSines[elevationIndex];
|
||||
for (
|
||||
let azimuthIndex = 0;
|
||||
azimuthIndex < azimuthSampleCount;
|
||||
azimuthIndex += 1
|
||||
) {
|
||||
const directionX = cosineElevation * azimuthCosines[azimuthIndex];
|
||||
const directionY = cosineElevation * azimuthSines[azimuthIndex];
|
||||
let summedReal = 0;
|
||||
let summedImaginary = 0;
|
||||
for (
|
||||
let microphoneIndex = 0;
|
||||
microphoneIndex < microphoneCount;
|
||||
microphoneIndex += 1
|
||||
) {
|
||||
const position = centeredPositions[microphoneIndex];
|
||||
const arrivalProjection =
|
||||
position[0] * directionX +
|
||||
position[1] * directionY +
|
||||
position[2] * directionZ;
|
||||
const phase = waveNumber *
|
||||
(arrivalProjection - steeringProjections[microphoneIndex]);
|
||||
summedReal += Math.cos(phase);
|
||||
summedImaginary += Math.sin(phase);
|
||||
}
|
||||
const flatIndex =
|
||||
elevationIndex * azimuthSampleCount + azimuthIndex;
|
||||
const real = summedReal / microphoneCount;
|
||||
const imaginary = summedImaginary / microphoneCount;
|
||||
const power = real * real + imaginary * imaginary;
|
||||
if (
|
||||
!Number.isFinite(real) ||
|
||||
!Number.isFinite(imaginary) ||
|
||||
!Number.isFinite(power)
|
||||
) {
|
||||
throw new RangeError(
|
||||
"receive beam-pattern sphere phasor accumulation became non-finite.",
|
||||
);
|
||||
}
|
||||
const magnitude = Math.sqrt(power);
|
||||
const decibels = power > 0 ? 10 * Math.log10(power) : -Infinity;
|
||||
responseReal[flatIndex] = real;
|
||||
responseImaginary[flatIndex] = imaginary;
|
||||
responsePower[flatIndex] = power;
|
||||
responseLinear[flatIndex] = magnitude;
|
||||
responseDb[flatIndex] = decibels;
|
||||
displayDb[flatIndex] = Math.max(dbFloor, decibels);
|
||||
sampledMaximumLinear = Math.max(sampledMaximumLinear, magnitude);
|
||||
sampledMaximumPower = Math.max(sampledMaximumPower, power);
|
||||
}
|
||||
}
|
||||
|
||||
const returnedFloat64Count =
|
||||
sampleCount * 6 + azimuthSampleCount + elevationSampleCount;
|
||||
return {
|
||||
model: "far-field-narrowband-conventional-delay-and-sum-receive-array-factor",
|
||||
surface: "full-sphere-equirectangular-samples",
|
||||
assumptions: Object.freeze({
|
||||
wavefront: "far-field-plane-wave",
|
||||
bandwidth: "narrowband",
|
||||
elements: "ideal-calibrated-omnidirectional-point-microphones",
|
||||
environment: "free-field",
|
||||
coordinates: "meters",
|
||||
}),
|
||||
coordinateSystem: Object.freeze({
|
||||
handedness: "right-handed",
|
||||
azimuth: "XY-plane-from-+X-toward-+Y",
|
||||
elevation: "from-XY-plane-toward-+Z",
|
||||
directionEquation:
|
||||
"[cos(elevation)*cos(azimuth), cos(elevation)*sin(azimuth), sin(elevation)]",
|
||||
}),
|
||||
frequencyHz,
|
||||
speedOfSound,
|
||||
wavelengthM: speedOfSound / frequencyHz,
|
||||
waveNumberRadPerM: waveNumber,
|
||||
steeringAzimuthDeg,
|
||||
steeringElevationDeg,
|
||||
steeringDirection,
|
||||
microphoneCount,
|
||||
azimuthSampleCount,
|
||||
elevationSampleCount,
|
||||
sampleCount,
|
||||
gridShape: [elevationSampleCount, azimuthSampleCount],
|
||||
flatIndexing: "elevation-major-row-major",
|
||||
evaluationCount: sampleEvaluationCount,
|
||||
dbFloor,
|
||||
grid: {
|
||||
topology: "equirectangular-azimuth-elevation",
|
||||
shape: [elevationSampleCount, azimuthSampleCount],
|
||||
flatIndex: "elevationIndex * azimuthSampleCount + azimuthIndex",
|
||||
azimuthEndpointDuplicated: false,
|
||||
elevationEndpointsIncluded: true,
|
||||
poleAzimuthSamplesCoincident: true,
|
||||
uniqueDirectionCount:
|
||||
azimuthSampleCount * (elevationSampleCount - 2) + 2,
|
||||
interpolation: "none",
|
||||
smoothing: "none",
|
||||
sampledMaximumNormalization: false,
|
||||
},
|
||||
workload: {
|
||||
spatialBaselineComparisons,
|
||||
maximumBaselineComparisons,
|
||||
sampleEvaluationCount,
|
||||
maximumEvaluationCount: sampleEvaluationCount,
|
||||
maximumEvaluations,
|
||||
returnedFloat64Count,
|
||||
returnedFloat64Bytes:
|
||||
returnedFloat64Count * Float64Array.BYTES_PER_ELEMENT,
|
||||
maximumSurfaceSamples: MAXIMUM_RECEIVE_BEAM_PATTERN_SPHERE_SAMPLES,
|
||||
},
|
||||
angularSampling: {
|
||||
mode: "fixed-equirectangular-grid",
|
||||
criterion: "maximum-cell-diagonal-pair-phase-step",
|
||||
azimuthStepDeg,
|
||||
elevationStepDeg,
|
||||
spatialApertureM,
|
||||
maximumPhaseMagnitudeRadBound,
|
||||
azimuthAdjacentDirectionChordBound,
|
||||
elevationAdjacentDirectionChordBound,
|
||||
maximumCellDiagonalDirectionChordBound,
|
||||
maximumAzimuthAdjacentPhaseStepRadBound,
|
||||
maximumElevationAdjacentPhaseStepRadBound,
|
||||
maximumCellDiagonalPhaseStepRadBound,
|
||||
phaseStepLimitRad:
|
||||
RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD,
|
||||
resolutionCriterionSatisfied,
|
||||
interpretation:
|
||||
"sampling-bound-only-not-proof-of-complete-lobe-detection",
|
||||
},
|
||||
azimuthAxisDeg,
|
||||
elevationAxisDeg,
|
||||
responseReal,
|
||||
responseImaginary,
|
||||
responsePower,
|
||||
responseLinear,
|
||||
responseDb,
|
||||
displayDb,
|
||||
sampledSummary: {
|
||||
maximumLinearOnGrid: sampledMaximumLinear,
|
||||
maximumPowerOnGrid: sampledMaximumPower,
|
||||
maximumDbOnGrid: sampledMaximumPower > 0
|
||||
? 10 * Math.log10(sampledMaximumPower)
|
||||
: -Infinity,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user