Elixir NIF bindings (Rustler / RustlerPrecompiled) for a Rust GCC-PHAT → SRP-PHAT acoustic source localizer over a known microphone-array geometry.

ExSrpPhat.localize/3 fuses a single time-aligned frame-set (one PCM frame per emplacement) against the array's known WGS-84 ECEF geometry into zero or more localized ExSrpPhat.Source structs. The NIF runs on a dirty CPU scheduler; PCM and geometry cross the boundary as packed binaries (never term lists), and the grid search is rayon-parallel.

I/O contract

@spec localize(frames, geometry, opts) :: {:ok, [ExSrpPhat.Source.t()]} | {:error, term()}

# frames:   [%{emplacement_id: term(), samples: [float()]}]   # one frame per emplacement, equal length
# geometry: %{sample_rate_hz: pos_integer(),
#             emplacements: [%{emplacement_id: term(), ecef: {x, y, z}}]}  # meters, WGS-84 ECEF
# opts:     keyword — :grid_extent_m, :coarse_res_m, :fine_res_m, :min_peak_ratio, :max_sources

Frames are matched to geometry by emplacement_id (order-independent); the two id sets must be identical. Input is fully validated in Elixir before crossing the NIF — bad input returns {:error, reason} (e.g. :too_few_emplacements, :emplacement_mismatch, :unequal_frame_lengths, {:invalid_sample_rate, v}).

Each detected source:

%ExSrpPhat.Source{
  ecef:                {x, y, z},          # meters ECEF
  velocity_mps:        {vx, vy, vz} | nil, # nil in v0 (Doppler deferred)
  radial_velocity:     float() | nil,      # nil in v0
  position_covariance: [9 floats] | nil,   # 3x3 ECEF row-major, from the TDOA system
  confidence:          0.0..1.0,           # normalized SRP peak sharpness
  dominant_hz:         float()             # dominant frequency of the steered peak signal
}

Source identity

This library is library-neutral and deliberately assigns no cross-solve source_ref. Stable identity is the caller's concern — dominant_hz is exposed so an adapter can reproduce a convention such as "band-#{round(dominant_hz)}".

Coordinate frame & units

Positions are WGS-84 ECEF meters. Two rules the math depends on:

  • Slant range is straight-line Euclidean (chord) distance, sqrt(dx²+dy²+dz²) — sound travels the chord, not a great-circle/surface arc. Emplacements at different altitudes are handled correctly.
  • Geometry is recentered at a reference emplacement before any squared term is formed (raw ECEF coords are ~5e6 m; |p_i|² − |p_0|² in raw coordinates loses all precision to catastrophic cancellation). The solve runs locally and shifts back.

Speed of sound is 343.0 m/s (~20 °C). Use ExSrpPhat.Geo to convert lat/lon/alt → ECEF (WGS-84: a = 6_378_137.0 m, f = 1/298.257223563); the forward transform is exact closed form, the inverse uses Bowring's method.

Field suffix / nameUnit
ecef, _m, position_covariancemeters (m², row-major, for covariance)
velocity_mps, radial_velocitymeters/second
sample_rate_hz, dominant_hzhertz
confidenceunitless, 0.0–1.0

Algorithm

  1. GCC-PHAT per emplacement pair: real FFT each channel (realfft), conj-multiply, phase-transform whitening (divide by magnitude), inverse FFT → a sharp correlation vs lag, robust to the source spectrum.
  2. Localization by closed-form TDOA multilateration. Sub-sample TDOAs are read from the correlation peaks; combinations across reference pairs (one peak per source) are each solved with the recentered linear system closed by the |s| = r₀ constraint, and the point-sampled SRP score keeps the genuine intersections — localizing overlapping sources without separating them. (A volumetric grid search is unusable here: the whitened peak is ~centimeters wide in position space, far sharper than any affordable grid; the brief's "recenter before squaring" gotcha is precisely the closed-form method.)
  3. Confidence from the pooled SRP response surface (rayon-parallel coarse grid): normalized peak sharpness relative to the field.
  4. Covariance from the linearized range-difference system (σ²·(JᵀJ)⁻¹).
  5. dominant_hz from the steered-and-summed peak signal's strongest spectral bin.

Doppler / velocity estimation is deferred (returns nil) — a single time-aligned frame-set does not constrain it.

Install

From hex (precompiled binaries, no Rust toolchain required):

def deps do
  [{:ex_srp_phat, "~> 0.1.0"}]
end

Or pin the git dependency directly:

{:ex_srp_phat, github: "cortfritz/ex_srp_phat", tag: "v0.1.0"}

Precompiled binaries vs. source builds

Releases ship precompiled NIF binaries for common platforms; rustler_precompiled downloads and checksum-verifies the right one at compile time, so consumers do not need a Rust toolchain. To force a source build (needs a Rust toolchain):

EX_SRP_PHAT_BUILD=1 mix deps.compile ex_srp_phat --force

The :dev and :test environments of this library always build from source.

Development

# Rust
cd native/srp_phat && cargo test && cargo clippy -- -D warnings && cargo fmt --check
# Elixir
EX_SRP_PHAT_BUILD=1 mix compile --warnings-as-errors && mix format --check-formatted && mix test

v0.1 scope

Single-frame localization of one or more sources, confidence, position covariance, and dominant frequency. Deferred: Doppler / velocity (velocity_mps, radial_velocity return nil).

License

MIT — see LICENSE.