feat: add physical receive beam pattern core

This commit is contained in:
2026-08-29 14:27:28 +09:00
parent 764cf28cf8
commit 9cc34df59b
4 changed files with 823 additions and 0 deletions
+494
View File
@@ -0,0 +1,494 @@
import { TAU } from "./math.js";
export const RECEIVE_BEAM_PATTERN_CUTS = Object.freeze(["xy", "xz", "yz"]);
export const DEFAULT_RECEIVE_BEAM_PATTERN_SAMPLES = 720;
export const DEFAULT_RECEIVE_BEAM_PATTERN_DB_FLOOR = -60;
export const MAXIMUM_RECEIVE_BEAM_PATTERN_EVALUATIONS = 16_000_000;
export const MAXIMUM_RECEIVE_BEAM_PATTERN_SAMPLES = 14_400;
export const MAXIMUM_RECEIVE_BEAM_PATTERN_MICROPHONES = 1_024;
export const RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD = Math.PI / 8;
const HALF_POWER = 0.5;
const RADIANS_PER_DEGREE = Math.PI / 180;
const HPBW_BISECTION_ITERATIONS = 48;
function requireFinite(value, label) {
const number = Number(value);
if (!Number.isFinite(number)) throw new TypeError(label + " must be finite.");
return number;
}
function requirePositive(value, label) {
const number = requireFinite(value, label);
if (!(number > 0)) throw new RangeError(label + " must be greater than zero.");
return number;
}
function requireCut(cut) {
if (!RECEIVE_BEAM_PATTERN_CUTS.includes(cut)) {
throw new RangeError(
"cut must be one of " + RECEIVE_BEAM_PATTERN_CUTS.join(", ") + ".",
);
}
return cut;
}
function requireMicrophones(array) {
const microphones = array?.microphones;
if (!Array.isArray(microphones) || microphones.length === 0) {
throw new TypeError("array.microphones must contain at least one microphone.");
}
if (microphones.length > MAXIMUM_RECEIVE_BEAM_PATTERN_MICROPHONES) {
throw new RangeError(
"array.microphones exceeds the receive beam-pattern limit of " +
MAXIMUM_RECEIVE_BEAM_PATTERN_MICROPHONES + ".",
);
}
return microphones.map((microphone, microphoneIndex) => {
const position = microphone?.position;
if (!Array.isArray(position) || position.length !== 3) {
throw new TypeError(
"array.microphones[" + microphoneIndex + "].position must contain x, y, z.",
);
}
return position.map((coordinate, axis) =>
requireFinite(
coordinate,
"array.microphones[" + microphoneIndex + "].position[" + axis + "]",
));
});
}
export function normalizeBeamPatternAngle(angleDeg) {
const angle = requireFinite(angleDeg, "angleDeg");
const normalized = ((angle % 360) + 360) % 360;
return Object.is(normalized, -0) ? 0 : normalized;
}
/**
* Convert a polar angle on an orthogonal great-circle cut to an array-to-source
* unit vector. Zero degrees points along the first named axis and +90 degrees
* points along the second: XY=(+x,+y), XZ=(+z,+x), YZ=(+z,+y).
*/
export function receiveBeamPatternDirection(cut, angleDeg) {
const plane = requireCut(cut);
const angleRadians = requireFinite(angleDeg, "angleDeg") * RADIANS_PER_DEGREE;
const cosine = Math.cos(angleRadians);
const sine = Math.sin(angleRadians);
if (plane === "xy") return [cosine, sine, 0];
if (plane === "xz") return [sine, 0, cosine];
return [0, sine, cosine];
}
function cutCoordinates(position, cut) {
if (cut === "xy") return [position[0], position[1]];
if (cut === "xz") return [position[2], position[0]];
return [position[2], position[1]];
}
function bisectHalfPowerBoundary(
insideOffsetDeg,
outsideOffsetDeg,
evaluatePowerAtOffset,
) {
let inside = insideOffsetDeg;
let outside = outsideOffsetDeg;
for (let iteration = 0; iteration < HPBW_BISECTION_ITERATIONS; iteration += 1) {
const middle = (inside + outside) / 2;
if (evaluatePowerAtOffset(middle) >= HALF_POWER) inside = middle;
else outside = middle;
}
return (inside + outside) / 2;
}
function halfPowerBeamwidth(
responsePower,
steeringIndex,
stepDeg,
steeringAngleDeg,
evaluatePowerAtOffset,
) {
const sampleCount = responsePower.length;
let allAboveHalfPower = true;
for (let index = 0; index < sampleCount; index += 1) {
if (responsePower[index] < HALF_POWER) allAboveHalfPower = false;
}
if (allAboveHalfPower) {
return {
status: "full-circle-above-half-power",
widthDeg: null,
startAngleDeg: null,
endAngleDeg: null,
fullCircleAboveHalfPower: true,
};
}
let leftSteps = 0;
while (leftSteps < sampleCount - 1) {
const nextIndex = (steeringIndex - leftSteps - 1 + sampleCount) % sampleCount;
if (responsePower[nextIndex] < HALF_POWER) break;
leftSteps += 1;
}
let rightSteps = 0;
while (rightSteps < sampleCount - leftSteps - 1) {
const nextIndex = (steeringIndex + rightSteps + 1) % sampleCount;
if (responsePower[nextIndex] < HALF_POWER) break;
rightSteps += 1;
}
if (leftSteps + rightSteps + 1 >= sampleCount) {
return {
status: "full-circle-above-half-power",
widthDeg: null,
startAngleDeg: null,
endAngleDeg: null,
fullCircleAboveHalfPower: true,
};
}
const leftBoundaryOffset = bisectHalfPowerBoundary(
-leftSteps * stepDeg,
-(leftSteps + 1) * stepDeg,
evaluatePowerAtOffset,
);
const rightBoundaryOffset = bisectHalfPowerBoundary(
rightSteps * stepDeg,
(rightSteps + 1) * stepDeg,
evaluatePowerAtOffset,
);
return {
status: "bounded",
widthDeg: rightBoundaryOffset - leftBoundaryOffset,
startAngleDeg: normalizeBeamPatternAngle(steeringAngleDeg + leftBoundaryOffset),
endAngleDeg: normalizeBeamPatternAngle(steeringAngleDeg + rightBoundaryOffset),
fullCircleAboveHalfPower: false,
};
}
function sampledLocalMaxima(responseLinear, anglesDeg, offsetsDeg) {
const sampleCount = responseLinear.length;
let minimum = Infinity;
let maximum = -Infinity;
for (const value of responseLinear) {
minimum = Math.min(minimum, value);
maximum = Math.max(maximum, value);
}
const comparisonTolerance = Math.max(1, maximum) * 64 * Number.EPSILON;
if (maximum - minimum <= comparisonTolerance) return [];
const peaks = [];
for (let index = 0; index < sampleCount; index += 1) {
const previous = responseLinear[(index - 1 + sampleCount) % sampleCount];
const current = responseLinear[index];
const next = responseLinear[(index + 1) % sampleCount];
if (
current >= previous - comparisonTolerance &&
current >= next - comparisonTolerance &&
(current > previous + comparisonTolerance || current > next + comparisonTolerance)
) {
peaks.push({
sampleIndex: index,
angleDeg: anglesDeg[index],
offsetDeg: offsetsDeg[index],
responseLinear: current,
responseDb: current > 0 ? 20 * Math.log10(current) : -Infinity,
});
}
}
peaks.sort((left, right) =>
right.responseLinear - left.responseLinear ||
Math.abs(left.offsetDeg) - Math.abs(right.offsetDeg) ||
left.angleDeg - right.angleDeg);
return peaks;
}
function maximumProjectedBaseline(coordinates) {
let maximum = 0;
for (let left = 0; left < coordinates.length; left += 1) {
for (let right = left + 1; right < coordinates.length; right += 1) {
maximum = Math.max(
maximum,
Math.hypot(
coordinates[left][0] - coordinates[right][0],
coordinates[left][1] - coordinates[right][1],
),
);
}
}
return maximum;
}
function recommendedSampleCount(waveNumber, projectedApertureM) {
if (projectedApertureM === 0) return 36;
const ratio = RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD /
(2 * waveNumber * projectedApertureM);
if (ratio >= 1) return 36;
const required = Math.ceil(Math.PI / Math.asin(ratio));
if (!Number.isSafeInteger(required)) return Number.MAX_SAFE_INTEGER;
return Math.ceil(required / 4) * 4;
}
/**
* Compute the ideal, calibrated, omnidirectional receive array factor for a
* narrowband far-field plane wave and conventional delay-and-sum steering.
*
* This function intentionally has no sample-rate input. frequencyHz is the
* analog acoustic hypothesis used for k=2πf/c, including frequencies above a
* later ADC Nyquist limit. The result is a great-circle slice, not a full 3-D
* directivity surface and not a probability distribution.
*/
export function computeReceiveBeamPattern(options) {
const array = options?.array;
const positions = requireMicrophones(array);
const frequencyHz = requirePositive(options?.frequencyHz, "frequencyHz");
const speedOfSound = requirePositive(options?.speedOfSound ?? 343, "speedOfSound");
const cut = requireCut(options?.cut ?? "xz");
const steeringAngleDeg = normalizeBeamPatternAngle(options?.steeringAngleDeg ?? 0);
const requestedSampleCount =
options?.sampleCount ?? DEFAULT_RECEIVE_BEAM_PATTERN_SAMPLES;
if (
requestedSampleCount !== "auto" &&
(!Number.isInteger(requestedSampleCount) ||
requestedSampleCount < 36 ||
requestedSampleCount > MAXIMUM_RECEIVE_BEAM_PATTERN_SAMPLES)
) {
throw new RangeError(
"sampleCount must be 'auto' or an integer inside 36.." +
MAXIMUM_RECEIVE_BEAM_PATTERN_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 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 centeredCutCoordinates = positions.map((position) =>
cutCoordinates([
position[0] - centroid[0],
position[1] - centroid[1],
position[2] - centroid[2],
], cut));
const projectedApertureM = maximumProjectedBaseline(centeredCutCoordinates);
const waveNumber = TAU * frequencyHz / speedOfSound;
if (!Number.isFinite(waveNumber) || !Number.isFinite(waveNumber * projectedApertureM)) {
throw new RangeError("frequency, speed of sound, and array coordinates overflow phase.");
}
const recommendedMetricSampleCount =
recommendedSampleCount(waveNumber, projectedApertureM);
const maximumMetricDirectionEvaluations = projectedApertureM > 0
? HPBW_BISECTION_ITERATIONS * 2
: 0;
const maximumSamplesByWork = Math.floor(
maximumEvaluations / microphoneCount - maximumMetricDirectionEvaluations,
);
const allowedSampleCount = Math.min(
MAXIMUM_RECEIVE_BEAM_PATTERN_SAMPLES,
maximumSamplesByWork,
);
if (allowedSampleCount < 36) {
throw new RangeError(
"maximumEvaluations cannot cover the minimum 36-angle receive pattern.",
);
}
const sampleCount = requestedSampleCount === "auto"
? Math.min(
allowedSampleCount,
Math.max(DEFAULT_RECEIVE_BEAM_PATTERN_SAMPLES, recommendedMetricSampleCount),
)
: requestedSampleCount;
const maximumMetricEvaluationCount =
microphoneCount * maximumMetricDirectionEvaluations;
const sampleEvaluationCount = microphoneCount * sampleCount;
const evaluationCount = sampleEvaluationCount + maximumMetricEvaluationCount;
if (!Number.isSafeInteger(evaluationCount) || evaluationCount > maximumEvaluations) {
throw new RangeError(
"receive beam pattern requires at most " + evaluationCount +
" microphone-angle evaluations; limit is " + maximumEvaluations + ".",
);
}
const stepDeg = 360 / sampleCount;
const stepRadians = TAU / sampleCount;
const maximumAdjacentPhaseStepRadBound =
2 * waveNumber * projectedApertureM * Math.sin(stepRadians / 2);
const metricResolutionSatisfied =
maximumAdjacentPhaseStepRadBound <=
RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD *
(1 + 64 * Number.EPSILON);
const steeringIndex = Math.floor(sampleCount / 2);
const anglesDeg = new Float64Array(sampleCount);
const offsetsDeg = new Float64Array(sampleCount);
const directionCosines = new Float64Array(sampleCount);
const directionSines = new Float64Array(sampleCount);
for (let index = 0; index < sampleCount; index += 1) {
const offsetDeg = (index - steeringIndex) * stepDeg;
const angleDeg = normalizeBeamPatternAngle(steeringAngleDeg + offsetDeg);
const radians = angleDeg * RADIANS_PER_DEGREE;
offsetsDeg[index] = offsetDeg;
anglesDeg[index] = angleDeg;
directionCosines[index] = Math.cos(radians);
directionSines[index] = Math.sin(radians);
}
const steeringRadians = steeringAngleDeg * RADIANS_PER_DEGREE;
const steeringCosine = Math.cos(steeringRadians);
const steeringSine = Math.sin(steeringRadians);
const summedReal = new Float64Array(sampleCount);
const summedImaginary = new Float64Array(sampleCount);
for (const [firstCoordinate, secondCoordinate] of centeredCutCoordinates) {
const steeringProjection =
firstCoordinate * steeringCosine + secondCoordinate * steeringSine;
for (let index = 0; index < sampleCount; index += 1) {
const arrivalProjection =
firstCoordinate * directionCosines[index] +
secondCoordinate * directionSines[index];
const phase = waveNumber * (arrivalProjection - steeringProjection);
summedReal[index] += Math.cos(phase);
summedImaginary[index] += Math.sin(phase);
}
}
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 maximumLinear = 0;
let maximumPower = 0;
for (let index = 0; index < sampleCount; index += 1) {
const real = summedReal[index] / microphoneCount;
const imaginary = summedImaginary[index] / microphoneCount;
const power = real * real + imaginary * imaginary;
const magnitude = Math.sqrt(power);
const decibels = power > 0 ? 10 * Math.log10(power) : -Infinity;
responseReal[index] = real;
responseImaginary[index] = imaginary;
responsePower[index] = power;
responseLinear[index] = magnitude;
responseDb[index] = decibels;
displayDb[index] = Math.max(dbFloor, decibels);
maximumLinear = Math.max(maximumLinear, magnitude);
maximumPower = Math.max(maximumPower, power);
}
let metricDirectionEvaluationCount = 0;
const evaluatePowerAtOffset = (offsetDeg) => {
metricDirectionEvaluationCount += 1;
const angleRadians =
(steeringAngleDeg + offsetDeg) * RADIANS_PER_DEGREE;
const directionCosine = Math.cos(angleRadians);
const directionSine = Math.sin(angleRadians);
let real = 0;
let imaginary = 0;
for (const [firstCoordinate, secondCoordinate] of centeredCutCoordinates) {
const steeringProjection =
firstCoordinate * steeringCosine + secondCoordinate * steeringSine;
const arrivalProjection =
firstCoordinate * directionCosine + secondCoordinate * directionSine;
const phase = waveNumber * (arrivalProjection - steeringProjection);
real += Math.cos(phase);
imaginary += Math.sin(phase);
}
return (real * real + imaginary * imaginary) /
(microphoneCount * microphoneCount);
};
const halfPower = metricResolutionSatisfied
? halfPowerBeamwidth(
responsePower,
steeringIndex,
stepDeg,
steeringAngleDeg,
evaluatePowerAtOffset,
)
: {
status: "resolution-unresolved",
widthDeg: null,
startAngleDeg: null,
endAngleDeg: null,
fullCircleAboveHalfPower: null,
};
const peaks = metricResolutionSatisfied
? sampledLocalMaxima(responseLinear, anglesDeg, offsetsDeg)
: [];
const steeringLinear = responseLinear[steeringIndex];
return {
model: "far-field-narrowband-conventional-delay-and-sum-receive-array-factor",
assumptions: Object.freeze({
wavefront: "far-field-plane-wave",
bandwidth: "narrowband",
elements: "ideal-calibrated-omnidirectional-point-microphones",
environment: "free-field",
coordinates: "meters",
}),
cut,
frequencyHz,
speedOfSound,
wavelengthM: speedOfSound / frequencyHz,
waveNumberRadPerM: waveNumber,
steeringAngleDeg,
steeringDirection: receiveBeamPatternDirection(cut, steeringAngleDeg),
sampleCount,
angularResolutionDeg: stepDeg,
microphoneCount,
evaluationCount: sampleEvaluationCount +
metricDirectionEvaluationCount * microphoneCount,
workload: {
projectedBaselineComparisons:
microphoneCount * (microphoneCount - 1) / 2,
sampleEvaluationCount,
maximumMetricEvaluationCount,
maximumEvaluationCount: evaluationCount,
},
angularSampling: {
mode: requestedSampleCount === "auto" ? "auto" : "fixed",
criterion: "maximum-adjacent-pair-phase-step",
projectedApertureM,
maximumAdjacentPhaseStepRadBound,
metricPhaseStepLimitRad:
RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD,
metricResolutionSatisfied,
recommendedSampleCount: recommendedMetricSampleCount,
autoCapped:
requestedSampleCount === "auto" &&
sampleCount < Math.max(
DEFAULT_RECEIVE_BEAM_PATTERN_SAMPLES,
recommendedMetricSampleCount,
),
},
dbFloor,
anglesDeg,
offsetsDeg,
responseReal,
responseImaginary,
responsePower,
responseLinear,
responseDb,
displayDb,
metrics: {
maximumLinear,
maximumPower,
maximumDb: maximumPower > 0 ? 10 * Math.log10(maximumPower) : -Infinity,
steeringLinear,
steeringDb: steeringLinear > 0 ? 20 * Math.log10(steeringLinear) : -Infinity,
halfPowerBeamwidth: halfPower,
sampledLocalMaxima: {
status: metricResolutionSatisfied ? "sampled" : "resolution-unresolved",
peaks,
},
},
};
}