#include "doa_algorithms.hpp" #include #include #include #include #include #include namespace esp32_doa { namespace { constexpr double kPi = 3.141592653589793238462643383279502884; constexpr double kTolerance = 1e-14; std::size_t index(const Stft &s, int snapshot, int frequency, int channel) { return (static_cast(snapshot) * s.bins + frequency) * s.channels + channel; } void fft(std::vector &x, bool inverse) { const std::size_t n = x.size(); if (n == 0 || (n & (n - 1)) != 0) { throw std::invalid_argument("nfft must be a power of two"); } for (std::size_t i = 1, j = 0; i < n; ++i) { std::size_t bit = n >> 1; for (; j & bit; bit >>= 1) j ^= bit; j ^= bit; if (i < j) std::swap(x[i], x[j]); } for (std::size_t len = 2; len <= n; len <<= 1) { const double sign = inverse ? 1.0 : -1.0; const Complex root = std::polar(1.0, sign * 2.0 * kPi / len); for (std::size_t start = 0; start < n; start += len) { Complex w(1.0, 0.0); const std::size_t half = len >> 1; for (std::size_t j = 0; j < half; ++j) { const Complex u = x[start + j]; const Complex v = x[start + j + half] * w; x[start + j] = u + v; x[start + j + half] = u - v; w *= root; } } } if (inverse) { for (Complex &v : x) v /= static_cast(n); } } double hann(int n, int length) { if (length <= 1) return 1.0; return 0.5 - 0.5 * std::cos(2.0 * kPi * n / (length - 1)); } std::vector frequency_bins(const Config &c) { const int first = static_cast(std::floor( c.min_frequency_hz * c.nfft / c.sample_rate + 0.5)); const int last = static_cast(std::floor( c.max_frequency_hz * c.nfft / c.sample_rate + 0.5)); std::vector bins; for (int k = first; k < last && k <= c.nfft / 2; ++k) if (k >= 0) bins.push_back(k); return bins; } void validate(const Config &c) { if (c.sample_rate <= 0 || c.channels <= 0 || c.nfft <= 0 || (c.nfft & (c.nfft - 1)) != 0 || c.hop <= 0 || c.hop > c.nfft) throw std::invalid_argument("invalid STFT configuration"); if (static_cast(c.microphone_azimuths_deg.size()) != c.channels) throw std::invalid_argument("one microphone azimuth is required per channel"); if (!(c.min_frequency_hz >= 0.0 && c.min_frequency_hz < c.max_frequency_hz && c.max_frequency_hz < c.sample_rate / 2.0)) throw std::invalid_argument("invalid frequency range"); } std::vector> microphone_positions(const Config &c) { std::vector> p(c.channels); for (int m = 0; m < c.channels; ++m) { const double a = c.microphone_azimuths_deg[m] * kPi / 180.0; p[m] = {c.radius_m * std::cos(a), c.radius_m * std::sin(a)}; } return p; } double wrap_degrees(double angle) { angle = std::fmod(angle, 360.0); return angle < 0.0 ? angle + 360.0 : angle; } std::vector mode_vector(const Config &c, int frequency, double angle_degrees) { const auto positions = microphone_positions(c); const double a = angle_degrees * kPi / 180.0; const double dx = std::cos(a), dy = std::sin(a); const double omega = 2.0 * kPi * c.sample_rate * frequency / c.nfft; std::vector mode(c.channels); for (int m = 0; m < c.channels; ++m) { const double tau = (dx * positions[m].first + dy * positions[m].second) / c.sound_speed_m_s; mode[m] = std::polar(1.0, omega * tau); } return mode; } std::vector solve_linear(std::vector a, std::vector b, int n) { for (int col = 0; col < n; ++col) { int pivot = col; double best = std::abs(a[col * n + col]); for (int row = col + 1; row < n; ++row) { const double value = std::abs(a[row * n + col]); if (value > best) best = value, pivot = row; } if (best < 1e-20) throw std::runtime_error("singular covariance matrix"); if (pivot != col) { for (int j = col; j < n; ++j) std::swap(a[col * n + j], a[pivot * n + j]); std::swap(b[col], b[pivot]); } const Complex diagonal = a[col * n + col]; for (int j = col; j < n; ++j) a[col * n + j] /= diagonal; b[col] /= diagonal; for (int row = 0; row < n; ++row) { if (row == col) continue; const Complex factor = a[row * n + col]; if (std::abs(factor) == 0.0) continue; for (int j = col; j < n; ++j) a[row * n + j] -= factor * a[col * n + j]; b[row] -= factor * b[col]; } } return b; } struct EigenResult { std::vector values; std::vector vectors; // column-major eigenvectors }; // Complex Hermitian Jacobi eigensolver. This small implementation is enough // for the fixed 8-microphone covariance matrices and avoids BLAS/LAPACK. EigenResult hermitian_eigen(const std::vector &input, int n) { std::vector a = input; std::vector v(static_cast(n) * n, Complex(0.0)); for (int i = 0; i < n; ++i) v[i * n + i] = 1.0; for (int iteration = 0; iteration < 80 * n * n; ++iteration) { int p = 0, q = 1; double largest = 0.0; for (int i = 0; i < n; ++i) for (int j = i + 1; j < n; ++j) if (std::abs(a[i * n + j]) > largest) largest = std::abs(a[i * n + j]), p = i, q = j; if (largest < 1e-12) break; const Complex b = a[p * n + q]; const double phase = std::arg(b); const double magnitude = std::abs(b); const double theta = 0.5 * std::atan2(2.0 * magnitude, a[q * n + q].real() - a[p * n + p].real()); const double cs = std::cos(theta), sn = std::sin(theta); const Complex ph = std::polar(1.0, phase); const double app = a[p * n + p].real(), aqq = a[q * n + q].real(); for (int k = 0; k < n; ++k) { if (k == p || k == q) continue; const Complex akp = a[k * n + p], akq = a[k * n + q]; a[k * n + p] = akp * ph * cs - akq * sn; a[p * n + k] = std::conj(a[k * n + p]); a[k * n + q] = akp * ph * sn + akq * cs; a[q * n + k] = std::conj(a[k * n + q]); } a[p * n + p] = app * cs * cs + aqq * sn * sn - 2.0 * magnitude * cs * sn; a[q * n + q] = app * sn * sn + aqq * cs * cs + 2.0 * magnitude * cs * sn; a[p * n + q] = a[q * n + p] = Complex(0.0); for (int k = 0; k < n; ++k) { const Complex vkp = v[k * n + p], vkq = v[k * n + q]; v[k * n + p] = vkp * ph * cs - vkq * sn; v[k * n + q] = vkp * ph * sn + vkq * cs; } } std::vector order(n); for (int i = 0; i < n; ++i) order[i] = i; std::sort(order.begin(), order.end(), [&](int x, int y) { return a[x * n + x].real() < a[y * n + y].real(); }); EigenResult result; result.values.resize(n); result.vectors.resize(static_cast(n) * n); for (int col = 0; col < n; ++col) { result.values[col] = a[order[col] * n + order[col]].real(); for (int row = 0; row < n; ++row) result.vectors[row * n + col] = v[row * n + order[col]]; } return result; } double spectrum_sharpness(const std::vector &spectrum) { if (spectrum.empty()) return 0.0; std::vector sorted = spectrum; std::sort(sorted.begin(), sorted.end()); const double peak = sorted.back(); const double floor = sorted[sorted.size() / 2]; const double spread = peak - sorted.front(); return std::clamp((peak - floor) / (spread + 1e-12), 0.0, 1.0); } DoaResult finish_spectrum(std::vector spectrum) { auto peak = std::max_element(spectrum.begin(), spectrum.end()); const int index = static_cast(peak - spectrum.begin()); DoaResult result; result.angle_degrees = wrap_degrees(360.0 * index / spectrum.size()); result.peak_sharpness = spectrum_sharpness(spectrum); result.spectrum = std::move(spectrum); return result; } } // namespace Complex &Stft::at(int snapshot, int frequency, int channel) { return spectra[index(*this, snapshot, frequency, channel)]; } const Complex &Stft::at(int snapshot, int frequency, int channel) const { return spectra[index(*this, snapshot, frequency, channel)]; } Stft analyze_stft(const std::vector &audio, int sample_count, const Config &config) { validate(config); if (sample_count < config.nfft || static_cast(sample_count) * config.channels > audio.size()) throw std::invalid_argument("audio does not contain enough samples"); Stft result; result.sample_count = sample_count; result.snapshots = (sample_count - config.nfft) / config.hop + 1; result.bins = config.nfft / 2 + 1; result.channels = config.channels; result.spectra.assign(static_cast(result.snapshots) * result.bins * result.channels, Complex(0.0)); std::vector means(config.channels, 0.0); for (int n = 0; n < sample_count; ++n) for (int m = 0; m < config.channels; ++m) means[m] += audio[static_cast(n) * config.channels + m] / sample_count; for (int s = 0; s < result.snapshots; ++s) { const int start = s * config.hop; for (int m = 0; m < config.channels; ++m) { std::vector frame(config.nfft); for (int n = 0; n < config.nfft; ++n) frame[n] = (audio[static_cast(start + n) * config.channels + m] - means[m]) * hann(n, config.nfft); fft(frame, false); for (int k = 0; k < result.bins; ++k) result.at(s, k, m) = frame[k]; } } return result; } DoaResult srp_phat(const Stft &stft, const Config &config) { validate(config); const std::vector bins = frequency_bins(config); const int pairs = config.channels * (config.channels - 1) / 2; std::vector response(config.grid_points, 0.0); const auto positions = microphone_positions(config); for (int g = 0; g < config.grid_points; ++g) { const double angle = 360.0 * g / config.grid_points * kPi / 180.0; const double dx = std::cos(angle), dy = std::sin(angle); double score = 0.0; for (int k : bins) { std::vector> cc(config.channels, std::vector(config.channels)); for (int i = 0; i < config.channels; ++i) for (int j = 0; j < config.channels; ++j) for (int s = 0; s < stft.snapshots; ++s) { Complex xi = stft.at(s, k, i), xj = stft.at(s, k, j); const double ai = std::max(std::abs(xi), kTolerance); const double aj = std::max(std::abs(xj), kTolerance); cc[i][j] += (xi / ai) * std::conj(xj / aj); } const double omega = 2.0 * kPi * config.sample_rate * k / config.nfft; for (int i = 0; i < config.channels; ++i) { const double ti = (dx * positions[i].first + dy * positions[i].second) / config.sound_speed_m_s; for (int j = i + 1; j < config.channels; ++j) { const double tj = (dx * positions[j].first + dy * positions[j].second) / config.sound_speed_m_s; const Complex mode = std::polar(1.0, omega * (tj - ti)); score += 2.0 * std::real(cc[i][j] * mode); } } } const double dc = static_cast(stft.snapshots * config.channels * bins.size()); response[g] = (score + dc) / (stft.snapshots * bins.size() * static_cast(pairs)); } return finish_spectrum(std::move(response)); } DoaResult norm_music(const Stft &stft, const Config &config) { validate(config); const std::vector bins = frequency_bins(config); std::vector per_frequency(static_cast(bins.size()) * config.grid_points, 0.0); for (std::size_t frequency_index = 0; frequency_index < bins.size(); ++frequency_index) { const int k = bins[frequency_index]; std::vector covariance(static_cast(config.channels) * config.channels, Complex(0.0)); for (int i = 0; i < config.channels; ++i) for (int j = 0; j < config.channels; ++j) for (int s = 0; s < stft.snapshots; ++s) covariance[i * config.channels + j] += stft.at(s, k, i) * std::conj(stft.at(s, k, j)) / stft.snapshots; const EigenResult eig = hermitian_eigen(covariance, config.channels); for (int g = 0; g < config.grid_points; ++g) { const auto mode = mode_vector(config, k, 360.0 * g / config.grid_points); Complex denom(0.0); for (int i = 0; i < config.channels; ++i) { for (int j = 0; j < config.channels; ++j) { Complex cross = (i == j ? Complex(1.0) : Complex(0.0)); cross -= eig.vectors[i * config.channels + config.channels - 1] * std::conj(eig.vectors[j * config.channels + config.channels - 1]); denom += std::conj(mode[i]) * cross * mode[j]; } } per_frequency[frequency_index * config.grid_points + g] = 1.0 / std::max(std::abs(denom), 1e-20); } } std::vector response(config.grid_points, 0.0); // Pyroomacoustics' NormMUSIC normalizes along the frequency axis for // every candidate direction (Pssl / max(Pssl, axis=0)). for (int g = 0; g < config.grid_points; ++g) { double maximum = 0.0; for (std::size_t f = 0; f < bins.size(); ++f) maximum = std::max(maximum, per_frequency[f * config.grid_points + g]); for (std::size_t f = 0; f < bins.size(); ++f) response[g] += per_frequency[f * config.grid_points + g] / std::max(maximum, 1e-20); response[g] /= bins.size(); } return finish_spectrum(std::move(response)); } MvdrResult mvdr(const Stft &stft, const Config &config, double angle_degrees) { validate(config); const std::vector bins = frequency_bins(config); const int n = config.channels; std::vector weights(static_cast(stft.bins) * n); for (int k = 0; k < stft.bins; ++k) { const auto steering = mode_vector(config, k, angle_degrees); for (int m = 0; m < n; ++m) weights[k * n + m] = steering[m] / n; } for (int k : bins) { std::vector covariance(static_cast(n) * n, Complex(0.0)); for (int i = 0; i < n; ++i) for (int j = 0; j < n; ++j) for (int s = 0; s < stft.snapshots; ++s) covariance[i * n + j] += stft.at(s, k, i) * std::conj(stft.at(s, k, j)) / stft.snapshots; double power = 0.0; for (int i = 0; i < n; ++i) power += covariance[i * n + i].real(); const double loading = config.diagonal_loading * std::max(power / n, 1e-12); for (int i = 0; i < n; ++i) covariance[i * n + i] += loading; std::vector solved = solve_linear(covariance, steering, n); Complex denominator(0.0); for (int m = 0; m < n; ++m) denominator += std::conj(steering[m]) * solved[m]; if (std::abs(denominator) < 1e-12) denominator = Complex(1e-12); for (int m = 0; m < n; ++m) weights[k * n + m] = solved[m] / denominator; } std::vector output(stft.sample_count, 0.0), normalization(stft.sample_count, 0.0); for (int s = 0; s < stft.snapshots; ++s) { std::vector spectrum(config.nfft, Complex(0.0)); for (int k = 0; k < stft.bins; ++k) { Complex value(0.0); for (int m = 0; m < n; ++m) value += std::conj(weights[k * n + m]) * stft.at(s, k, m); spectrum[k] = value; if (k > 0 && k < config.nfft / 2) spectrum[config.nfft - k] = std::conj(value); } fft(spectrum, true); const int start = s * config.hop; for (int i = 0; i < config.nfft; ++i) { const double w = hann(i, config.nfft); output[start + i] += spectrum[i].real() * w; normalization[start + i] += w * w; } } for (int i = 0; i < stft.sample_count; ++i) output[i] = normalization[i] > 1e-12 ? output[i] / normalization[i] : 0.0; return {std::move(output)}; } DoaResult estimate(const std::vector &audio, int sample_count, const Config &config, const std::string &algorithm) { const Stft stft = analyze_stft(audio, sample_count, config); if (algorithm == "SRP-PHAT" || algorithm == "SRP" || algorithm == "SRP-PATH") return srp_phat(stft, config); if (algorithm == "NormMUSIC" || algorithm == "MUSIC" || algorithm == "NORMMUSIC") return norm_music(stft, config); throw std::invalid_argument("unknown algorithm: " + algorithm); } MvdrResult beamform(const std::vector &audio, int sample_count, const Config &config, double angle_degrees) { return mvdr(analyze_stft(audio, sample_count, config), config, angle_degrees); } } // namespace esp32_doa