feat: add physical 3d receive pattern core

This commit is contained in:
2026-08-30 10:22:13 +09:00
parent 6804eca6ed
commit dda72739a9
2 changed files with 886 additions and 0 deletions
+407
View File
@@ -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,
},
};
}