feat: add physical receive beam pattern core
This commit is contained in:
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./activity-monitor.js";
|
export * from "./activity-monitor.js";
|
||||||
|
export * from "./beam-pattern.js";
|
||||||
export * from "./cfar.js";
|
export * from "./cfar.js";
|
||||||
export * from "./das.js";
|
export * from "./das.js";
|
||||||
export * from "./evaluation.js";
|
export * from "./evaluation.js";
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ globalThis.assertEqual = (actual, expected, message = "values differ") => {
|
|||||||
try {
|
try {
|
||||||
await import("/tests/unit/contracts.test.js");
|
await import("/tests/unit/contracts.test.js");
|
||||||
await import("/tests/unit/acoustics.test.js");
|
await import("/tests/unit/acoustics.test.js");
|
||||||
|
await import("/tests/unit/beam-pattern.test.js");
|
||||||
await import("/tests/unit/cfar.test.js");
|
await import("/tests/unit/cfar.test.js");
|
||||||
await import("/tests/unit/activity-monitor.test.js");
|
await import("/tests/unit/activity-monitor.test.js");
|
||||||
await import("/tests/unit/spectral-flux.test.js");
|
await import("/tests/unit/spectral-flux.test.js");
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import {
|
||||||
|
computeReceiveBeamPattern,
|
||||||
|
receiveBeamPatternDirection,
|
||||||
|
} from "/packages/acoustics-core/beam-pattern.js";
|
||||||
|
|
||||||
|
function assertNear(actual, expected, tolerance, label) {
|
||||||
|
assert(
|
||||||
|
Math.abs(actual - expected) <= tolerance,
|
||||||
|
label + ": expected " + expected + " ± " + tolerance + ", received " + actual,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayFromPositions(positions) {
|
||||||
|
return {
|
||||||
|
microphones: positions.map((position, index) => ({
|
||||||
|
id: "mic-" + String(index + 1),
|
||||||
|
position,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleAtAngle(pattern, angleDeg) {
|
||||||
|
let bestIndex = 0;
|
||||||
|
let bestDistance = Infinity;
|
||||||
|
for (let index = 0; index < pattern.anglesDeg.length; index += 1) {
|
||||||
|
const delta = Math.abs(pattern.anglesDeg[index] - angleDeg);
|
||||||
|
const circularDistance = Math.min(delta, 360 - delta);
|
||||||
|
if (circularDistance < bestDistance) {
|
||||||
|
bestDistance = circularDistance;
|
||||||
|
bestIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(bestDistance < 1e-10, "requested angle is present in sampled cut");
|
||||||
|
return pattern.responseLinear[bestIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
test("receive beam-pattern cuts use the documented right-handed axes", () => {
|
||||||
|
const cases = [
|
||||||
|
["xy", 0, [1, 0, 0]],
|
||||||
|
["xy", 90, [0, 1, 0]],
|
||||||
|
["xz", 0, [0, 0, 1]],
|
||||||
|
["xz", 90, [1, 0, 0]],
|
||||||
|
["yz", 0, [0, 0, 1]],
|
||||||
|
["yz", 90, [0, 1, 0]],
|
||||||
|
];
|
||||||
|
for (const [cut, angleDeg, expected] of cases) {
|
||||||
|
const actual = receiveBeamPatternDirection(cut, angleDeg);
|
||||||
|
actual.forEach((value, axis) =>
|
||||||
|
assertNear(value, expected[axis], 1e-12, cut + " direction axis " + axis));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one ideal point microphone is omnidirectional on every sampled cut", () => {
|
||||||
|
const pattern = computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([[12.5, -7, 3]]),
|
||||||
|
frequencyHz: 49_125,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "yz",
|
||||||
|
steeringAngleDeg: -35,
|
||||||
|
sampleCount: 360,
|
||||||
|
});
|
||||||
|
for (const response of pattern.responseLinear) {
|
||||||
|
assertNear(response, 1, 1e-12, "single-microphone response");
|
||||||
|
}
|
||||||
|
assertEqual(
|
||||||
|
pattern.metrics.halfPowerBeamwidth.status,
|
||||||
|
"full-circle-above-half-power",
|
||||||
|
"flat cut status",
|
||||||
|
);
|
||||||
|
assertEqual(pattern.metrics.halfPowerBeamwidth.widthDeg, null, "flat cut has no HPBW");
|
||||||
|
assert(
|
||||||
|
pattern.metrics.halfPowerBeamwidth.fullCircleAboveHalfPower,
|
||||||
|
"flat cut records the missing crossing",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
pattern.metrics.sampledLocalMaxima.peaks.length,
|
||||||
|
0,
|
||||||
|
"flat cut has no unique peaks",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("half-wavelength two-microphone broadside cut has nulls, 60-degree HPBW, and front-back tie", () => {
|
||||||
|
const speedOfSound = 343;
|
||||||
|
const frequencyHz = 1_000;
|
||||||
|
const spacing = speedOfSound / frequencyHz / 2;
|
||||||
|
const pattern = computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([[-spacing / 2, 0, 0], [spacing / 2, 0, 0]]),
|
||||||
|
frequencyHz,
|
||||||
|
speedOfSound,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: 1_440,
|
||||||
|
});
|
||||||
|
assertNear(sampleAtAngle(pattern, 90), 0, 1e-12, "endfire null at +x");
|
||||||
|
assertNear(sampleAtAngle(pattern, 270), 0, 1e-12, "endfire null at -x");
|
||||||
|
assertNear(sampleAtAngle(pattern, 0), 1, 1e-12, "steered broadside response");
|
||||||
|
assertNear(sampleAtAngle(pattern, 180), 1, 1e-12, "planar front-back response");
|
||||||
|
assertEqual(pattern.metrics.halfPowerBeamwidth.status, "bounded", "HPBW status");
|
||||||
|
assertNear(pattern.metrics.halfPowerBeamwidth.widthDeg, 60, 1e-10, "power HPBW");
|
||||||
|
const zeroDbAngles = pattern.metrics.sampledLocalMaxima.peaks
|
||||||
|
.filter((peak) => Math.abs(peak.responseDb) < 1e-10)
|
||||||
|
.map((peak) => peak.angleDeg)
|
||||||
|
.sort((left, right) => left - right);
|
||||||
|
assertEqual(zeroDbAngles.join(","), "0,180", "front-back maxima remain tied");
|
||||||
|
assertNear(pattern.responseReal[Math.floor(pattern.sampleCount / 2)], 1, 1e-12, "raw real");
|
||||||
|
assertNear(pattern.responseImaginary[Math.floor(pattern.sampleCount / 2)], 0, 1e-12, "raw imaginary");
|
||||||
|
assertNear(pattern.responsePower[Math.floor(pattern.sampleCount / 2)], 1, 1e-12, "raw power");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asymmetric array preserves the receive steering sign in raw complex response", () => {
|
||||||
|
const speedOfSound = 343;
|
||||||
|
const frequencyHz = 1_000;
|
||||||
|
const quarterWavelength = speedOfSound / frequencyHz / 4;
|
||||||
|
const pattern = computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([
|
||||||
|
[0, 0, 0],
|
||||||
|
[quarterWavelength, 0, 0],
|
||||||
|
[3 * quarterWavelength, 0, 0],
|
||||||
|
]),
|
||||||
|
frequencyHz,
|
||||||
|
speedOfSound,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: 360,
|
||||||
|
});
|
||||||
|
const lookIndex = [...pattern.anglesDeg].findIndex((angle) => angle === 90);
|
||||||
|
assert(lookIndex >= 0, "+x look direction is sampled");
|
||||||
|
assertNear(pattern.responseReal[lookIndex], -1 / 6, 1e-12, "signed real response");
|
||||||
|
assertNear(
|
||||||
|
pattern.responseImaginary[lookIndex],
|
||||||
|
-Math.sqrt(3) / 6,
|
||||||
|
1e-12,
|
||||||
|
"signed imaginary response",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("display dB floor never changes raw array factor or physical metrics", () => {
|
||||||
|
const array = arrayFromPositions([[-0.08575, 0, 0], [0.08575, 0, 0]]);
|
||||||
|
const shared = {
|
||||||
|
array,
|
||||||
|
frequencyHz: 1_000,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: 720,
|
||||||
|
};
|
||||||
|
const shallow = computeReceiveBeamPattern({ ...shared, dbFloor: -30 });
|
||||||
|
const deep = computeReceiveBeamPattern({ ...shared, dbFloor: -100 });
|
||||||
|
for (let index = 0; index < shallow.sampleCount; index += 1) {
|
||||||
|
assertNear(
|
||||||
|
shallow.responsePower[index],
|
||||||
|
deep.responsePower[index],
|
||||||
|
0,
|
||||||
|
"raw power is floor-independent at " + index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertNear(
|
||||||
|
shallow.metrics.halfPowerBeamwidth.widthDeg,
|
||||||
|
deep.metrics.halfPowerBeamwidth.widthDeg,
|
||||||
|
0,
|
||||||
|
"HPBW is floor-independent",
|
||||||
|
);
|
||||||
|
const nullIndex = [...shallow.anglesDeg].findIndex((angle) => angle === 90);
|
||||||
|
assertEqual(shallow.displayDb[nullIndex], -30, "shallow display floor");
|
||||||
|
assertEqual(deep.displayDb[nullIndex], -100, "deep display floor");
|
||||||
|
assert(
|
||||||
|
shallow.responseDb[nullIndex] < -300,
|
||||||
|
"raw null remains below both display floors",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("receive array factor is invariant to translating every microphone", () => {
|
||||||
|
const basePositions = [[-0.12, 0.03, 0], [0.04, -0.08, 0.02], [0.15, 0.07, -0.01]];
|
||||||
|
const translation = [1_000, -2_000, 500];
|
||||||
|
const translatedPositions = basePositions.map((position) =>
|
||||||
|
position.map((coordinate, axis) => coordinate + translation[axis]));
|
||||||
|
const options = {
|
||||||
|
frequencyHz: 7_500,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "xy",
|
||||||
|
steeringAngleDeg: 23,
|
||||||
|
sampleCount: 720,
|
||||||
|
};
|
||||||
|
const base = computeReceiveBeamPattern({ ...options, array: arrayFromPositions(basePositions) });
|
||||||
|
const translated = computeReceiveBeamPattern({
|
||||||
|
...options,
|
||||||
|
array: arrayFromPositions(translatedPositions),
|
||||||
|
});
|
||||||
|
for (let index = 0; index < base.sampleCount; index += 1) {
|
||||||
|
assertNear(
|
||||||
|
translated.responseLinear[index],
|
||||||
|
base.responseLinear[index],
|
||||||
|
1e-11,
|
||||||
|
"translation-invariant sample " + index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("beam pattern uses analog frequency directly without Nyquist folding", () => {
|
||||||
|
const highFrequencyHz = 49_125;
|
||||||
|
const speedOfSound = 343;
|
||||||
|
const halfHighWavelength = speedOfSound / (2 * highFrequencyHz);
|
||||||
|
const array = arrayFromPositions([[0, 0, 0], [halfHighWavelength, 0, 0]]);
|
||||||
|
const highAnalog = computeReceiveBeamPattern({
|
||||||
|
array,
|
||||||
|
frequencyHz: highFrequencyHz,
|
||||||
|
speedOfSound,
|
||||||
|
cut: "xy",
|
||||||
|
steeringAngleDeg: 90,
|
||||||
|
sampleCount: 360,
|
||||||
|
});
|
||||||
|
const foldedTemporalAlias = computeReceiveBeamPattern({
|
||||||
|
array,
|
||||||
|
frequencyHz: 1_125,
|
||||||
|
speedOfSound,
|
||||||
|
cut: "xy",
|
||||||
|
steeringAngleDeg: 90,
|
||||||
|
sampleCount: 360,
|
||||||
|
});
|
||||||
|
assertEqual(highAnalog.frequencyHz, highFrequencyHz, "analog hypothesis is preserved");
|
||||||
|
assertNear(
|
||||||
|
highAnalog.wavelengthM,
|
||||||
|
speedOfSound / highFrequencyHz,
|
||||||
|
1e-15,
|
||||||
|
"analog wavelength",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Math.abs(sampleAtAngle(highAnalog, 0) - sampleAtAngle(foldedTemporalAlias, 0)) > 0.1,
|
||||||
|
"spatial pattern is not computed from the folded ADC alias",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coarse high-frequency cuts preserve raw samples but withhold unresolved metrics", () => {
|
||||||
|
const pattern = computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([[-0.5, 0, 0], [0.5, 0, 0]]),
|
||||||
|
frequencyHz: 100_000,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: 360,
|
||||||
|
});
|
||||||
|
assert(!pattern.angularSampling.metricResolutionSatisfied, "phase-step audit fails");
|
||||||
|
assert(
|
||||||
|
pattern.angularSampling.maximumAdjacentPhaseStepRadBound >
|
||||||
|
pattern.angularSampling.metricPhaseStepLimitRad,
|
||||||
|
"result exposes the physical phase-step bound",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
pattern.metrics.halfPowerBeamwidth.status,
|
||||||
|
"resolution-unresolved",
|
||||||
|
"HPBW is withheld",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
pattern.metrics.sampledLocalMaxima.status,
|
||||||
|
"resolution-unresolved",
|
||||||
|
"peak interpretation is withheld",
|
||||||
|
);
|
||||||
|
assertEqual(pattern.responsePower.length, 360, "raw sampled power remains available");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("auto angular sampling increases resolution and reports when the safety cap is insufficient", () => {
|
||||||
|
const moderate = computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([[-0.225, 0, 0], [0.225, 0, 0]]),
|
||||||
|
frequencyHz: 49_125,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: "auto",
|
||||||
|
});
|
||||||
|
assert(moderate.sampleCount > 720, "auto mode increases angular samples");
|
||||||
|
assert(moderate.angularSampling.metricResolutionSatisfied, "moderate case becomes resolved");
|
||||||
|
assert(!moderate.angularSampling.autoCapped, "moderate case fits the cap");
|
||||||
|
|
||||||
|
const extreme = computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([[-10, 0, 0], [10, 0, 0]]),
|
||||||
|
frequencyHz: 1_000_000,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: "auto",
|
||||||
|
});
|
||||||
|
assertEqual(extreme.sampleCount, 14_400, "auto mode obeys the angular cap");
|
||||||
|
assert(extreme.angularSampling.autoCapped, "cap is explicit");
|
||||||
|
assert(!extreme.angularSampling.metricResolutionSatisfied, "extreme metric remains unresolved");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("beam-pattern preflight rejects invalid input and excessive work", () => {
|
||||||
|
const array = arrayFromPositions([[0, 0, 0], [0.1, 0, 0]]);
|
||||||
|
let frequencyError = null;
|
||||||
|
try {
|
||||||
|
computeReceiveBeamPattern({ array, frequencyHz: 0 });
|
||||||
|
} catch (error) {
|
||||||
|
frequencyError = error;
|
||||||
|
}
|
||||||
|
assert(frequencyError instanceof RangeError, "zero analog frequency is rejected");
|
||||||
|
|
||||||
|
let workError = null;
|
||||||
|
try {
|
||||||
|
computeReceiveBeamPattern({
|
||||||
|
array,
|
||||||
|
frequencyHz: 1_000,
|
||||||
|
sampleCount: 360,
|
||||||
|
maximumEvaluations: 911,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
workError = error;
|
||||||
|
}
|
||||||
|
assert(workError instanceof RangeError, "work cap is checked before evaluation");
|
||||||
|
assert(workError.message.includes("912"), "error reports maximum evaluation count");
|
||||||
|
|
||||||
|
let microphoneLimitError = null;
|
||||||
|
try {
|
||||||
|
computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions(
|
||||||
|
Array.from({ length: 1_025 }, (_, index) => [index / 1000, 0, 0]),
|
||||||
|
),
|
||||||
|
frequencyHz: 1_000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
microphoneLimitError = error;
|
||||||
|
}
|
||||||
|
assert(microphoneLimitError instanceof RangeError, "microphone cap precedes pairwise audit");
|
||||||
|
assert(
|
||||||
|
microphoneLimitError.message.includes("1024"),
|
||||||
|
"microphone cap is explicit",
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user