Files
JustOneCacophony/src/hrtf/sofa_field.cpp
T
TheM14 42d6da6302
ci / ubuntu-latest (push) Failing after 13s
ci / macos-latest (push) Has been cancelled
ci / windows-latest (push) Has been cancelled
Initial pure C++ implementation
2026-09-25 04:17:30 +08:00

1844 lines
83 KiB
C++

#include "hrtf/sofa_field.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <complex>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <limits>
#include <string>
#include <utility>
#include <vector>
#include "foundation/fft.h"
#include "foundation/sha256.h"
#include "hrtf/public_filterbank.h"
#include "io/npy_writer.h"
namespace joc::hrtf {
namespace {
using Complex = std::complex<double>;
constexpr double kPi = 3.14159265358979323846;
constexpr int kParameterCount = 2 * kHybridBands;
constexpr std::int32_t kFilterbankFormatVersion = 1;
constexpr int kQmfBands = 64;
// Hybrid-band centre frequencies of the public analysis bank at 48 kHz
// (public_filterbank._BAND_CENTER_FREQUENCIES_HZ; part of the cache contract).
const double kBandCenters[kHybridBands] = {
53.19564095937407, 26.3876219849709, 140.9074183269806, 98.55238901464415,
234.09258166297573, 344.8745321543293, 321.80435900819805, 401.38762185365727,
476.19239745597804, 473.552388917001, 648.8076026837931, 719.8745321201852,
780.1254679168173, 851.1923973714038, 1023.8076024849751, 1155.125467695814,
1293.0325067063661, 1668.032511377253, 2043.0325074138086, 2456.967490229666,
2831.96748495363, 3206.967488172826, 3581.9675052705525, 3918.0325089666067,
4331.967500201844, 4706.967500350216, 5043.032510771545, 5456.9674914391635,
5831.967490225267, 6168.0324885741875, 6543.032508972284, 6956.96748844665,
7293.032503259869, 7668.032503551393, 8043.032504806491, 8418.032498852166,
8793.032502508235, 9206.967488589786, 9543.032511549152, 9956.967486913867,
10293.032513008677, 10668.032507835102, 11043.032513641429, 11456.967482937946,
11793.032513984212, 12206.967486015788, 12543.032517062376, 12956.96748635822,
13331.967492165066, 13706.967486991198, 14043.032513086031, 14456.967488451,
14793.032511410214, 15206.967497492202, 15581.967501147887, 15956.967495193188,
16331.96749644876, 16706.967496740173, 17043.03251155318, 17456.96749102773,
17831.967511425748, 18168.032509774734, 18543.032508560515, 18956.967489228293,
19293.032499649784, 19668.032499798002, 20081.967491033392, 20418.0324947296,
20793.032511827063, 21168.032515046092, 21543.032509770488, 21956.967492586176,
22331.96748862257, 22706.967493293465, 23043.03251375972, 23418.03251061199,
23831.96749768645,
};
Status field_fail(joc_error code, const std::string& message) {
return Status::fail(code, stage::kRender, message);
}
double dot3(const double* a, const double* b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
void cross3(const double* a, const double* b, double* out) {
out[0] = a[1] * b[2] - a[2] * b[1];
out[1] = a[2] * b[0] - a[0] * b[2];
out[2] = a[0] * b[1] - a[1] * b[0];
}
bool normalize3(double* value) {
const double length = std::sqrt(dot3(value, value));
if (!std::isfinite(length) || length <= 1.0e-15) {
return false;
}
value[0] /= length;
value[1] /= length;
value[2] /= length;
return true;
}
// Python's round-half-even, used for the reference's shell and direction keys.
double round_half_even(double value, int digits) {
const double scale = std::pow(10.0, digits);
const double scaled = value * scale;
const double lower = std::floor(scaled);
const double fraction = scaled - lower;
double rounded = lower;
if (fraction > 0.5 || (fraction == 0.5 && std::fmod(lower, 2.0) != 0.0)) {
rounded = lower + 1.0;
}
return rounded / scale;
}
// numpy.percentile with linear interpolation.
double percentile(std::vector<double> values, double percent) {
if (values.empty()) {
return 0.0;
}
std::sort(values.begin(), values.end());
const double position = (static_cast<double>(values.size()) - 1.0) * percent / 100.0;
const std::size_t lower = static_cast<std::size_t>(std::floor(position));
const std::size_t upper = static_cast<std::size_t>(std::ceil(position));
if (lower == upper || upper >= values.size()) {
return values[std::min(lower, values.size() - 1u)];
}
const double fraction = position - static_cast<double>(lower);
return values[lower] * (1.0 - fraction) + values[upper] * fraction;
}
// Port of sofa_canonical.shift_signal_fft: positive shift delays, negative advances.
std::vector<double> shift_signal_fft(const std::vector<double>& values, double shift) {
const std::size_t count = values.size();
std::vector<double> result(count, 0.0);
if (std::abs(shift) < 1.0e-12) {
return values;
}
const std::size_t guard =
std::max<std::size_t>(128u, static_cast<std::size_t>(std::ceil(std::abs(shift))) + 64u);
const std::size_t fft_size = dsp::next_fast_len(count + 2u * guard);
std::vector<Complex> spectrum(fft_size, Complex(0.0, 0.0));
for (std::size_t index = 0u; index < count; ++index) {
spectrum[guard + index] = Complex(values[index], 0.0);
}
std::vector<Complex> transformed;
dsp::fft_any(spectrum, false, &transformed);
const std::size_t half = fft_size / 2u;
for (std::size_t bin = 0u; bin <= half; ++bin) {
const double angle = -2.0 * kPi * static_cast<double>(bin) * shift /
static_cast<double>(fft_size);
transformed[bin] *= std::polar(1.0, angle);
}
for (std::size_t bin = half + 1u; bin < fft_size; ++bin) {
const double angle = -2.0 * kPi * static_cast<double>(bin - fft_size) * shift /
static_cast<double>(fft_size);
transformed[bin] *= std::polar(1.0, angle);
}
std::vector<Complex> restored;
dsp::fft_any(transformed, true, &restored);
for (std::size_t index = 0u; index < count; ++index) {
result[index] = restored[guard + index].real();
}
return result;
}
// public_filterbank.hybrid_gain_synthesis_dictionary for one FIR length.
std::vector<double> hybrid_gain_synthesis_dictionary(std::size_t sample_count,
const Kernels& kernels) {
const std::size_t total =
((kLatencySamples + sample_count + 512u + kQmfHop - 1u) / kQmfHop) * kQmfHop;
const std::size_t slots = total / kQmfHop;
std::vector<double> impulse(total, 0.0);
impulse[0] = 1.0;
PublicFilterbank analysis_bank(kernels, 1u);
std::vector<Complex> base;
analysis_bank.analyze_full_rate(impulse, slots, &base);
std::vector<Complex> hybrid(slots * kParameterCount * kHybridBands, Complex(0.0, 0.0));
for (std::size_t slot = 0u; slot < slots; ++slot) {
for (int band = 0; band < kHybridBands; ++band) {
const Complex value = base[slot * kHybridBands + static_cast<std::size_t>(band)];
hybrid[(slot * kParameterCount + static_cast<std::size_t>(2 * band)) * kHybridBands +
static_cast<std::size_t>(band)] = value;
hybrid[(slot * kParameterCount + static_cast<std::size_t>(2 * band + 1)) * kHybridBands +
static_cast<std::size_t>(band)] = Complex(0.0, 1.0) * value;
}
}
PublicFilterbank synthesis_bank(kernels, kParameterCount);
std::vector<double> rendered;
synthesis_bank.synthesize_full_rate(hybrid, slots, &rendered);
std::vector<double> dictionary(sample_count * kParameterCount, 0.0);
for (std::size_t tap = 0u; tap < sample_count; ++tap) {
for (int parameter = 0; parameter < kParameterCount; ++parameter) {
dictionary[tap * kParameterCount + static_cast<std::size_t>(parameter)] =
rendered[(kLatencySamples + tap) * kParameterCount + static_cast<std::size_t>(parameter)];
}
}
return dictionary;
}
// -------------------------------------------------------- linear algebra ----
// LU with partial pivoting, complex (mirrors the LAPACK zgesv path the reference
// takes when the ridge system is cast to complex for a complex right-hand side).
bool lu_solve_complex(std::vector<Complex>* matrix, int order, std::vector<Complex>* rhs,
int columns) {
std::vector<int> pivot(static_cast<std::size_t>(order));
for (int step = 0; step < order; ++step) {
int best = step;
double best_magnitude = std::abs((*matrix)[static_cast<std::size_t>(step) * order + step]);
for (int row = step + 1; row < order; ++row) {
const double magnitude =
std::abs((*matrix)[static_cast<std::size_t>(row) * order + step]);
if (magnitude > best_magnitude) {
best_magnitude = magnitude;
best = row;
}
}
pivot[static_cast<std::size_t>(step)] = best;
if (best != step) {
for (int column = 0; column < order; ++column) {
std::swap((*matrix)[static_cast<std::size_t>(step) * order + column],
(*matrix)[static_cast<std::size_t>(best) * order + column]);
}
}
const Complex diagonal = (*matrix)[static_cast<std::size_t>(step) * order + step];
if (std::abs(diagonal) <= 1.0e-300) {
return false;
}
for (int row = step + 1; row < order; ++row) {
const Complex factor =
(*matrix)[static_cast<std::size_t>(row) * order + step] / diagonal;
(*matrix)[static_cast<std::size_t>(row) * order + step] = factor;
for (int column = step + 1; column < order; ++column) {
(*matrix)[static_cast<std::size_t>(row) * order + column] -=
factor * (*matrix)[static_cast<std::size_t>(step) * order + column];
}
}
}
for (int column = 0; column < columns; ++column) {
for (int step = 0; step < order; ++step) {
const int source = pivot[static_cast<std::size_t>(step)];
if (source != step) {
std::swap((*rhs)[static_cast<std::size_t>(step) * columns + column],
(*rhs)[static_cast<std::size_t>(source) * columns + column]);
}
}
for (int row = 1; row < order; ++row) {
Complex sum = (*rhs)[static_cast<std::size_t>(row) * columns + column];
for (int column2 = 0; column2 < row; ++column2) {
sum -= (*matrix)[static_cast<std::size_t>(row) * order + column2] *
(*rhs)[static_cast<std::size_t>(column2) * columns + column];
}
(*rhs)[static_cast<std::size_t>(row) * columns + column] = sum;
}
for (int row = order - 1; row >= 0; --row) {
Complex sum = (*rhs)[static_cast<std::size_t>(row) * columns + column];
for (int column2 = row + 1; column2 < order; ++column2) {
sum -= (*matrix)[static_cast<std::size_t>(row) * order + column2] *
(*rhs)[static_cast<std::size_t>(column2) * columns + column];
}
(*rhs)[static_cast<std::size_t>(row) * columns + column] =
sum / (*matrix)[static_cast<std::size_t>(row) * order + row];
}
}
return true;
}
// LU with partial pivoting, real (the LAPACK dgesv path).
bool lu_solve_real(std::vector<double>* matrix, int order, std::vector<double>* rhs, int columns) {
std::vector<int> pivot(static_cast<std::size_t>(order));
for (int step = 0; step < order; ++step) {
int best = step;
double best_magnitude = std::abs((*matrix)[static_cast<std::size_t>(step) * order + step]);
for (int row = step + 1; row < order; ++row) {
const double magnitude = std::abs((*matrix)[static_cast<std::size_t>(row) * order + step]);
if (magnitude > best_magnitude) {
best_magnitude = magnitude;
best = row;
}
}
pivot[static_cast<std::size_t>(step)] = best;
if (best != step) {
for (int column = 0; column < order; ++column) {
std::swap((*matrix)[static_cast<std::size_t>(step) * order + column],
(*matrix)[static_cast<std::size_t>(best) * order + column]);
}
}
const double diagonal = (*matrix)[static_cast<std::size_t>(step) * order + step];
if (std::abs(diagonal) <= 1.0e-300) {
return false;
}
for (int row = step + 1; row < order; ++row) {
const double factor = (*matrix)[static_cast<std::size_t>(row) * order + step] / diagonal;
(*matrix)[static_cast<std::size_t>(row) * order + step] = factor;
for (int column = step + 1; column < order; ++column) {
(*matrix)[static_cast<std::size_t>(row) * order + column] -=
factor * (*matrix)[static_cast<std::size_t>(step) * order + column];
}
}
}
for (int column = 0; column < columns; ++column) {
for (int step = 0; step < order; ++step) {
const int source = pivot[static_cast<std::size_t>(step)];
if (source != step) {
std::swap((*rhs)[static_cast<std::size_t>(step) * columns + column],
(*rhs)[static_cast<std::size_t>(source) * columns + column]);
}
}
for (int row = 1; row < order; ++row) {
double sum = (*rhs)[static_cast<std::size_t>(row) * columns + column];
for (int column2 = 0; column2 < row; ++column2) {
sum -= (*matrix)[static_cast<std::size_t>(row) * order + column2] *
(*rhs)[static_cast<std::size_t>(column2) * columns + column];
}
(*rhs)[static_cast<std::size_t>(row) * columns + column] = sum;
}
for (int row = order - 1; row >= 0; --row) {
double sum = (*rhs)[static_cast<std::size_t>(row) * columns + column];
for (int column2 = row + 1; column2 < order; ++column2) {
sum -= (*matrix)[static_cast<std::size_t>(row) * order + column2] *
(*rhs)[static_cast<std::size_t>(column2) * columns + column];
}
(*rhs)[static_cast<std::size_t>(row) * columns + column] =
sum / (*matrix)[static_cast<std::size_t>(row) * order + row];
}
}
return true;
}
// ---------------------------------------------------- spherical harmonics ---
// P_degree^order(x) with the Condon-Shortley phase (spherical_harmonics.py).
double associated_legendre(int order, int degree, double x) {
double p_mm = 1.0;
if (order != 0) {
double double_factorial = 1.0;
for (int value = 1; value < 2 * order; value += 2) {
double_factorial *= static_cast<double>(value);
}
p_mm = (order % 2 == 0 ? 1.0 : -1.0) * double_factorial *
std::pow(std::max(0.0, 1.0 - x * x), 0.5 * static_cast<double>(order));
}
if (degree == order) {
return p_mm;
}
double previous_previous = p_mm;
double previous = x * static_cast<double>(2 * order + 1) * p_mm;
if (degree == order + 1) {
return previous;
}
for (int current = order + 2; current <= degree; ++current) {
const double value =
(static_cast<double>(2 * current - 1) * x * previous -
static_cast<double>(current + order - 1) * previous_previous) /
static_cast<double>(current - order);
previous_previous = previous;
previous = value;
}
return previous;
}
double factorial(int value) {
double result = 1.0;
for (int index = 2; index <= value; ++index) {
result *= static_cast<double>(index);
}
return result;
}
// [M, (order+1)^2] orthonormal real harmonics in ACN order.
void real_spherical_harmonics(const std::vector<double>& directions, std::size_t count, int order,
std::vector<double>* basis) {
const std::size_t terms = static_cast<std::size_t>((order + 1) * (order + 1));
basis->assign(count * terms, 0.0);
for (std::size_t point = 0u; point < count; ++point) {
double unit[3] = {directions[point * 3u], directions[point * 3u + 1u],
directions[point * 3u + 2u]};
if (!normalize3(unit)) {
unit[0] = 1.0;
unit[1] = 0.0;
unit[2] = 0.0;
}
const double azimuth = std::atan2(unit[1], unit[0]);
const double cos_colatitude = std::min(1.0, std::max(-1.0, unit[2]));
std::size_t column = 0u;
for (int degree = 0; degree <= order; ++degree) {
for (int m = -degree; m <= degree; ++m) {
const int absolute = std::abs(m);
const double normalization =
std::sqrt((2.0 * static_cast<double>(degree) + 1.0) / (4.0 * kPi) *
factorial(degree - absolute) / factorial(degree + absolute));
const double legendre = associated_legendre(absolute, degree, cos_colatitude);
double value = 0.0;
if (m < 0) {
value = std::sqrt(2.0) * normalization * legendre *
std::sin(static_cast<double>(absolute) * azimuth);
} else if (m > 0) {
value = std::sqrt(2.0) * normalization * legendre *
std::cos(static_cast<double>(m) * azimuth);
} else {
value = normalization * legendre;
}
(*basis)[point * terms + column] = value;
++column;
}
}
}
}
// Area weights of an irregular full-sphere grid (spherical_harmonics.py).
//
// The Voronoi cell of p_i is the intersection of the hemispheres
// dot(x, p_i - p_j) >= 0. Projected gnomonicly onto the tangent plane at p_i
// that intersection is a convex polygon of half-planes, so the cell can be
// clipped directly without building a convex hull: this has no triangulation
// ambiguity on cocircular grids (latitude rings) and reproduces the reference's
// spherical Voronoi areas to roundoff. The spherical area is the solid-angle fan
// from p_i over the cell's corners.
std::vector<double> spherical_voronoi_weights(const std::vector<double>& directions,
std::size_t count) {
std::vector<double> weights(count, 1.0 / static_cast<double>(count));
if (count < 4u) {
return weights;
}
constexpr double kBox = 4.0; // a cap of ~76 degrees: far larger than any cell
std::vector<double> areas(count, 0.0);
std::vector<std::pair<double, double>> polygon;
std::vector<std::pair<double, double>> clipped;
for (std::size_t point = 0u; point < count; ++point) {
double p[3] = {directions[point * 3u], directions[point * 3u + 1u],
directions[point * 3u + 2u]};
if (!normalize3(p)) {
return weights;
}
double helper[3] = {0.0, 0.0, 1.0};
if (std::abs(p[2]) >= 0.9) {
helper[0] = 1.0;
helper[2] = 0.0;
}
double east[3];
double north[3];
cross3(helper, p, east);
if (!normalize3(east)) {
return weights;
}
cross3(p, east, north);
polygon.clear();
polygon.emplace_back(-kBox, -kBox);
polygon.emplace_back(kBox, -kBox);
polygon.emplace_back(kBox, kBox);
polygon.emplace_back(-kBox, kBox);
for (std::size_t other = 0u; other < count && polygon.size() >= 3u; ++other) {
if (other == point) {
continue;
}
const double* q = directions.data() + other * 3u;
const double cosine = dot3(p, q);
const double a = dot3(east, q);
const double b = dot3(north, q);
clipped.clear();
for (std::size_t index = 0u; index < polygon.size(); ++index) {
const std::pair<double, double>& current = polygon[index];
const std::pair<double, double>& next = polygon[(index + 1u) % polygon.size()];
const double value = (1.0 - cosine) - current.first * a - current.second * b;
const double next_value = (1.0 - cosine) - next.first * a - next.second * b;
if (value >= 0.0) {
clipped.push_back(current);
}
if ((value >= 0.0) != (next_value >= 0.0)) {
const double fraction = value / (value - next_value);
clipped.emplace_back(current.first + fraction * (next.first - current.first),
current.second + fraction * (next.second - current.second));
}
}
polygon.swap(clipped);
}
if (polygon.size() < 3u) {
return weights; // a cell that does not close: keep the uniform fallback
}
double area = 0.0;
for (std::size_t index = 0u; index < polygon.size(); ++index) {
const std::pair<double, double>& current = polygon[index];
const std::pair<double, double>& next = polygon[(index + 1u) % polygon.size()];
double first[3] = {p[0] + current.first * east[0] + current.second * north[0],
p[1] + current.first * east[1] + current.second * north[1],
p[2] + current.first * east[2] + current.second * north[2]};
double second[3] = {p[0] + next.first * east[0] + next.second * north[0],
p[1] + next.first * east[1] + next.second * north[1],
p[2] + next.first * east[2] + next.second * north[2]};
if (!normalize3(first) || !normalize3(second)) {
return weights;
}
double cross[3];
cross3(first, second, cross);
const double determinant = dot3(p, cross);
const double denominator =
1.0 + dot3(p, first) + dot3(first, second) + dot3(second, p);
area += std::abs(2.0 * std::atan2(determinant, denominator));
}
if (!std::isfinite(area) || area <= 0.0) {
return weights;
}
areas[point] = area;
}
double sum = 0.0;
for (const double area : areas) {
sum += area;
}
if (!(sum > 0.0) || !std::isfinite(sum)) {
return weights;
}
for (std::size_t point = 0u; point < count; ++point) {
weights[point] = areas[point] / sum;
}
return weights;
}
// ---------------------------------------------------------- coordinates -----
double length_factor(const std::string& token, bool* found) {
static const std::pair<const char*, double> kTable[] = {
{"m", 1.0}, {"metre", 1.0}, {"metres", 1.0}, {"meter", 1.0},
{"meters", 1.0}, {"cm", 1.0e-2}, {"centimetre", 1.0e-2}, {"centimetres", 1.0e-2},
{"centimeter", 1.0e-2}, {"centimeters", 1.0e-2}, {"mm", 1.0e-3}, {"millimetre", 1.0e-3},
{"millimetres", 1.0e-3}, {"millimeter", 1.0e-3}, {"millimeters", 1.0e-3},
};
for (const auto& entry : kTable) {
if (token == entry.first) {
*found = true;
return entry.second;
}
}
*found = false;
return 0.0;
}
bool angle_in_radians(const std::string& token, double value, double* out) {
if (token == "degree" || token == "degrees") {
*out = value * (kPi / 180.0);
return true;
}
if (token == "radian" || token == "radians") {
*out = value;
return true;
}
return false;
}
std::vector<std::string> split_units(const std::string& units) {
std::vector<std::string> tokens;
std::string current;
for (const char character : units) {
if (character == ',') {
const std::size_t begin = current.find_first_not_of(" \t");
const std::size_t end = current.find_last_not_of(" \t");
if (begin != std::string::npos) {
std::string token = current.substr(begin, end - begin + 1u);
std::transform(token.begin(), token.end(), token.begin(),
[](unsigned char value) {
return static_cast<char>(std::tolower(value));
});
tokens.push_back(token);
}
current.clear();
} else {
current.push_back(character);
}
}
const std::size_t begin = current.find_first_not_of(" \t");
const std::size_t end = current.find_last_not_of(" \t");
if (begin != std::string::npos) {
std::string token = current.substr(begin, end - begin + 1u);
std::transform(token.begin(), token.end(), token.begin(), [](unsigned char value) {
return static_cast<char>(std::tolower(value));
});
tokens.push_back(token);
}
return tokens;
}
std::string lowered(const std::string& text) {
std::string result = text;
const std::size_t begin = result.find_first_not_of(" \t");
const std::size_t end = result.find_last_not_of(" \t");
result = begin == std::string::npos ? std::string() : result.substr(begin, end - begin + 1u);
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char value) {
return static_cast<char>(std::tolower(value));
});
return result;
}
// coordinates_to_cartesian_m: spherical or Cartesian rows to metres, in place.
Status convert_coordinates(const std::vector<double>& values, std::size_t rows,
const std::string& type, const std::string& units,
const std::string& variable, std::vector<double>* out) {
if (values.size() != rows * 3u) {
return field_fail(JOC_ERR_HRTF_FORMAT, variable + " must contain finite C=3 coordinates");
}
for (const double value : values) {
if (!std::isfinite(value)) {
return field_fail(JOC_ERR_HRTF_FORMAT,
variable + " must contain finite C=3 coordinates");
}
}
const std::string kind = lowered(type);
const std::vector<std::string> tokens = split_units(units);
out->assign(values.size(), 0.0);
if (kind == "cartesian") {
double factors[3] = {0.0, 0.0, 0.0};
if (tokens.size() == 1u) {
bool found = false;
const double factor = length_factor(tokens[0], &found);
if (!found) {
return field_fail(JOC_ERR_HRTF_FORMAT,
"unsupported Cartesian units for " + variable + ": " + units);
}
factors[0] = factors[1] = factors[2] = factor;
} else if (tokens.size() == 3u) {
for (int axis = 0; axis < 3; ++axis) {
bool found = false;
factors[axis] = length_factor(tokens[static_cast<std::size_t>(axis)], &found);
if (!found) {
return field_fail(JOC_ERR_HRTF_FORMAT,
"unsupported Cartesian units for " + variable + ": " + units);
}
}
} else {
return field_fail(JOC_ERR_HRTF_FORMAT,
"unsupported Cartesian units for " + variable + ": " + units);
}
for (std::size_t row = 0u; row < rows; ++row) {
for (int axis = 0; axis < 3; ++axis) {
(*out)[row * 3u + static_cast<std::size_t>(axis)] =
values[row * 3u + static_cast<std::size_t>(axis)] * factors[axis];
}
}
return Status::success();
}
if (kind != "spherical" || tokens.size() != 3u) {
return field_fail(JOC_ERR_HRTF_FORMAT, "unsupported coordinates for " + variable +
": Type=" + type + ", Units=" + units);
}
bool radius_found = false;
const double radius_factor = length_factor(tokens[2], &radius_found);
if (!radius_found) {
return field_fail(JOC_ERR_HRTF_FORMAT,
"unsupported spherical radius unit for " + variable + ": " + units);
}
for (std::size_t row = 0u; row < rows; ++row) {
double azimuth = 0.0;
double elevation = 0.0;
if (!angle_in_radians(tokens[0], values[row * 3u], &azimuth) ||
!angle_in_radians(tokens[1], values[row * 3u + 1u], &elevation)) {
return field_fail(JOC_ERR_HRTF_FORMAT,
"unsupported spherical angle units for " + variable + ": " + units);
}
const double radius = values[row * 3u + 2u] * radius_factor;
if (radius < 0.0) {
return field_fail(JOC_ERR_HRTF_FORMAT,
variable + " contains a negative spherical radius");
}
const double horizontal = std::cos(elevation);
(*out)[row * 3u] = radius * horizontal * std::cos(azimuth);
(*out)[row * 3u + 1u] = radius * horizontal * std::sin(azimuth);
(*out)[row * 3u + 2u] = radius * std::sin(elevation);
}
return Status::success();
}
// ------------------------------------------------------------- JSON ---------
std::string json_escape(const std::string& text) {
std::string out = "\"";
for (const char character : text) {
const unsigned char value = static_cast<unsigned char>(character);
switch (character) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
default:
if (value < 0x20u) {
char buffer[8];
std::snprintf(buffer, sizeof(buffer), "\\u%04x", value);
out += buffer;
} else {
out.push_back(character);
}
break;
}
}
out += "\"";
return out;
}
std::string json_number(double value) { return io::python_float_repr(value); }
// Python's float.hex(), the representation json.dumps uses for the cache-key
// payload, so the key matches the reference byte for byte.
std::string python_hex(double value) {
if (std::isnan(value)) {
return "nan";
}
if (std::isinf(value)) {
return value > 0.0 ? "inf" : "-inf";
}
if (value == 0.0) {
return std::signbit(value) ? "-0x0.0p+0" : "0x0.0p+0";
}
std::uint64_t bits = 0u;
std::memcpy(&bits, &value, sizeof(bits));
const bool negative = (bits >> 63u) != 0u;
const int exponent = static_cast<int>((bits >> 52u) & 0x7FFu);
const std::uint64_t mantissa = bits & 0xFFFFFFFFFFFFFull;
char buffer[40];
if (exponent == 0) {
std::snprintf(buffer, sizeof(buffer), "%s0x0.%013llxp-1022", negative ? "-" : "",
static_cast<unsigned long long>(mantissa));
} else {
std::snprintf(buffer, sizeof(buffer), "%s0x1.%013llxp%+d", negative ? "-" : "",
static_cast<unsigned long long>(mantissa), exponent - 1023);
}
return buffer;
}
using JsonMembers = std::vector<std::pair<std::string, std::string>>;
std::string json_object(JsonMembers members) {
std::sort(members.begin(), members.end(),
[](const std::pair<std::string, std::string>& left,
const std::pair<std::string, std::string>& right) {
return left.first < right.first;
});
std::string out = "{";
for (std::size_t index = 0u; index < members.size(); ++index) {
if (index != 0u) {
out += ",";
}
out += json_escape(members[index].first) + ":" + members[index].second;
}
out += "}";
return out;
}
std::string json_array(const std::vector<std::string>& values) {
std::string out = "[";
for (std::size_t index = 0u; index < values.size(); ++index) {
if (index != 0u) {
out += ",";
}
out += values[index];
}
out += "]";
return out;
}
// ------------------------------------------------------- cache contract -----
std::string sha256_hex_upper(const void* data, std::size_t size) {
crypto::Sha256 hash;
hash.update(data, size);
std::string digest = hash.finish_hex();
std::transform(digest.begin(), digest.end(), digest.begin(), [](unsigned char value) {
return static_cast<char>(std::toupper(value));
});
return digest;
}
std::string to_upper(std::string text) {
std::transform(text.begin(), text.end(), text.begin(), [](unsigned char value) {
return static_cast<char>(std::toupper(value));
});
return text;
}
// filterbank_fingerprint(): stable identifiers of the embedded public tables.
std::string filterbank_fingerprint_json() {
const Kernels& kernels = builtin_kernels();
auto hash_float32 = [](const std::vector<double>& values) {
std::vector<float> narrowed(values.size());
for (std::size_t index = 0u; index < values.size(); ++index) {
narrowed[index] = static_cast<float>(values[index]);
}
return sha256_hex_upper(narrowed.data(), narrowed.size() * sizeof(float));
};
auto hash_float64 = [](const std::vector<double>& values) {
return sha256_hex_upper(values.data(), values.size() * sizeof(double));
};
const std::string band_centers_sha =
sha256_hex_upper(kBandCenters, static_cast<std::size_t>(kHybridBands) * sizeof(double));
const JsonMembers arrays = {
// The fingerprint also covers the archive's format_version member.
{"format_version",
json_escape(sha256_hex_upper(&kFilterbankFormatVersion, sizeof(kFilterbankFormatVersion)))},
{"hybrid_analysis_low_kernel", json_escape(hash_float32(kernels.hybrid_low))},
{"hybrid_synthesis_indices",
json_escape(sha256_hex_upper(kernels.hybrid_indices.data(),
kernels.hybrid_indices.size() * sizeof(std::int16_t)))},
{"hybrid_synthesis_values", json_escape(hash_float32(kernels.hybrid_values))},
{"qmf_analysis_coefficients", json_escape(hash_float32(kernels.qmf_analysis))},
{"qmf_synthesis_basis", json_escape(hash_float64(kernels.qmf_basis))},
{"qmf_synthesis_taps", json_escape(hash_float64(kernels.qmf_taps))},
};
return json_object({
{"archive_sha256", json_escape(kFilterbankArchiveSha256)},
{"array_sha256", json_object(arrays)},
{"band_centers_sha256", json_escape(band_centers_sha)},
{"table_version", json_escape(kFilterbankTableVersion)},
});
}
} // namespace
std::vector<double> hybrid_gain_synthesis_dictionary_for_check(std::size_t sample_count) {
return hybrid_gain_synthesis_dictionary(sample_count, builtin_kernels());
}
std::vector<double> analysis_impulse_for_check(std::size_t total_samples) {
const std::size_t slots = total_samples / kQmfHop;
std::vector<double> impulse(total_samples, 0.0);
impulse[0] = 1.0;
PublicFilterbank bank(builtin_kernels(), 1u);
std::vector<Complex> base;
bank.analyze_full_rate(impulse, slots, &base);
std::vector<double> out(base.size() * 2u, 0.0);
for (std::size_t index = 0u; index < base.size(); ++index) {
out[index * 2u] = base[index].real();
out[index * 2u + 1u] = base[index].imag();
}
return out;
}
const std::vector<double>& hybrid_band_center_frequencies_hz() {
static const std::vector<double> centers(kBandCenters, kBandCenters + kHybridBands);
return centers;
}
std::string compiled_hrtf_cache_key(const std::string& source_sha256, double sample_rate_hz,
double shell_radius_m, int order, double projection_ridge,
double sh_ridge) {
const std::string payload = json_object({
{"compiler_version", json_escape(kCompilerVersion)},
{"filterbank", filterbank_fingerprint_json()},
{"order", std::to_string(order)},
{"phase_policy_version", json_escape(kPhasePolicyVersion)},
{"projection_ridge", json_escape(python_hex(projection_ridge))},
{"sh_convention", json_escape(kShConvention)},
{"shell_radius_m", json_escape(python_hex(shell_radius_m))},
{"source_sha256", json_escape(to_upper(source_sha256))},
{"spherical_harmonic_ridge", json_escape(python_hex(sh_ridge))},
{"target_sample_rate_hz", json_escape(python_hex(sample_rate_hz))},
});
const std::string encoded = std::string("JOC-HRTF-CACHE-KEY-V1\0", 22u) + payload;
return to_upper(sha256_hex_upper(encoded.data(), encoded.size()));
}
std::string field_payload_sha256(const Field& field) {
crypto::Sha256 hash;
const char prefix[] = "JOC-HRTF-CACHE-PAYLOAD-V1";
hash.update(prefix, sizeof(prefix) - 1u);
const std::uint8_t zero = 0u;
hash.update(&zero, 1u);
auto feed = [&](const char* name, const char* dtype, const std::string& shape,
const void* data, std::size_t size) {
const std::string name_text(name);
const std::string dtype_text(dtype);
hash.update(name_text.data(), name_text.size());
hash.update(&zero, 1u);
hash.update(dtype_text.data(), dtype_text.size());
hash.update(&zero, 1u);
hash.update(shape.data(), shape.size());
hash.update(&zero, 1u);
hash.update(data, size);
};
feed("band_center_frequencies_hz", "<f8", "[77]", field.band_centers_hz.data(),
field.band_centers_hz.size() * sizeof(double));
feed("coefficients", "<c16", "[36, 2, 77]", field.coefficients.data(),
field.coefficients.size() * sizeof(double));
feed("delay_coefficients", "<f8", "[36, 2]", field.delay_coefficients.data(),
field.delay_coefficients.size() * sizeof(double));
feed("delay_bounds", "<f8", "[2, 2]", field.delay_bounds.data(),
field.delay_bounds.size() * sizeof(double));
return to_upper(hash.finish_hex());
}
std::string cache_file_name(const std::string& display_name, const std::string& cache_key) {
std::string stem = "hrtf";
if (!display_name.empty()) {
const std::size_t slash = display_name.find_last_of("/\\");
stem = slash == std::string::npos ? display_name : display_name.substr(slash + 1u);
const std::size_t dot = stem.find_last_of('.');
if (dot != std::string::npos && dot != 0u) {
stem = stem.substr(0u, dot);
}
}
std::string safe;
for (const char character : stem) {
const bool allowed = (character >= 'A' && character <= 'Z') ||
(character >= 'a' && character <= 'z') ||
(character >= '0' && character <= '9') || character == '.' ||
character == '_' || character == '-';
safe.push_back(allowed ? character : '_');
}
const std::size_t begin = safe.find_first_not_of("._");
const std::size_t end = safe.find_last_not_of("._");
safe = begin == std::string::npos ? std::string() : safe.substr(begin, end - begin + 1u);
if (safe.empty()) {
safe = "hrtf";
}
return safe + "." + cache_key.substr(0u, 20u) + ".jochrtf";
}
Status write_jochrtf(const Field& field, const std::string& path) {
if (field.metadata_json.empty() || field.payload_sha256.empty()) {
return field_fail(JOC_ERR_INVALID_ARGUMENT,
"compiled HRTF field has no metadata to write");
}
std::vector<io::NpyMember> members;
auto add_real = [&](const char* name, const std::vector<double>& values,
std::vector<std::uint64_t> shape) {
io::NpyMember member;
member.name = name;
member.descr = "<f8";
member.shape = std::move(shape);
member.data.resize(values.size() * sizeof(double));
std::memcpy(member.data.data(), values.data(), member.data.size());
members.push_back(std::move(member));
};
add_real("band_center_frequencies_hz", field.band_centers_hz, {kHybridBands});
{
io::NpyMember member;
member.name = "coefficients";
member.descr = "<c16";
member.shape = {kFieldTerms, 2u, kHybridBands};
member.data.resize(field.coefficients.size() * sizeof(double));
std::memcpy(member.data.data(), field.coefficients.data(), member.data.size());
members.push_back(std::move(member));
}
add_real("delay_coefficients", field.delay_coefficients, {kFieldTerms, 2u});
add_real("delay_bounds", field.delay_bounds, {2u, 2u});
{
// metadata_json is a NumPy Unicode scalar string: UTF-32LE, no terminator.
io::NpyMember member;
member.name = "metadata_json";
member.descr = "<U" + std::to_string(field.metadata_json.size());
member.shape = {};
member.data = io::utf8_to_utf32le(field.metadata_json);
members.push_back(std::move(member));
}
std::string error;
if (!io::write_zip(path, members, &error)) {
return field_fail(JOC_ERR_OUTPUT_WRITE, "cannot write compiled HRTF cache: " + error);
}
return Status::success();
}
namespace {
// -------------------------------------------------------- normal system -----
void normal_system(const std::vector<double>& basis, std::size_t count, int terms,
const std::vector<double>& weights, double ridge,
std::vector<double>* system, std::vector<double>* weighted_basis) {
weighted_basis->assign(count * static_cast<std::size_t>(terms), 0.0);
for (std::size_t row = 0u; row < count; ++row) {
for (int term = 0; term < terms; ++term) {
(*weighted_basis)[row * static_cast<std::size_t>(terms) + static_cast<std::size_t>(term)] =
basis[row * static_cast<std::size_t>(terms) + static_cast<std::size_t>(term)] *
weights[row];
}
}
system->assign(static_cast<std::size_t>(terms) * static_cast<std::size_t>(terms), 0.0);
for (std::size_t row = 0u; row < count; ++row) {
for (int left = 0; left < terms; ++left) {
const double value =
basis[row * static_cast<std::size_t>(terms) + static_cast<std::size_t>(left)];
if (value == 0.0) {
continue;
}
for (int right = 0; right < terms; ++right) {
(*system)[static_cast<std::size_t>(left) * static_cast<std::size_t>(terms) +
static_cast<std::size_t>(right)] +=
value * (*weighted_basis)[row * static_cast<std::size_t>(terms) +
static_cast<std::size_t>(right)];
}
}
}
double trace = 0.0;
for (int term = 0; term < terms; ++term) {
trace += (*system)[static_cast<std::size_t>(term) * static_cast<std::size_t>(terms) +
static_cast<std::size_t>(term)];
}
const double scale = trace / static_cast<double>(terms);
for (int term = 0; term < terms; ++term) {
(*system)[static_cast<std::size_t>(term) * static_cast<std::size_t>(terms) +
static_cast<std::size_t>(term)] += ridge * scale;
}
}
// fit_real_spherical_harmonics for complex targets: [terms, columns].
std::vector<Complex> fit_complex(const std::vector<double>& basis, std::size_t count, int terms,
const std::vector<double>& weights, double ridge,
const std::vector<Complex>& values, std::size_t columns) {
std::vector<double> system;
std::vector<double> weighted_basis;
normal_system(basis, count, terms, weights, ridge, &system, &weighted_basis);
std::vector<Complex> matrix(system.size());
for (std::size_t index = 0u; index < system.size(); ++index) {
matrix[index] = Complex(system[index], 0.0);
}
std::vector<Complex> right(static_cast<std::size_t>(terms) * columns, Complex(0.0, 0.0));
for (std::size_t row = 0u; row < count; ++row) {
for (int term = 0; term < terms; ++term) {
const double basis_value =
basis[row * static_cast<std::size_t>(terms) + static_cast<std::size_t>(term)];
if (basis_value == 0.0) {
continue;
}
for (std::size_t column = 0u; column < columns; ++column) {
right[static_cast<std::size_t>(term) * columns + column] +=
basis_value * weights[row] * values[row * columns + column];
}
}
}
lu_solve_complex(&matrix, terms, &right, static_cast<int>(columns));
return right;
}
// fit_real_spherical_harmonics for real targets: [terms, columns].
std::vector<double> fit_real(const std::vector<double>& basis, std::size_t count, int terms,
const std::vector<double>& weights, double ridge,
const std::vector<double>& values, std::size_t columns) {
std::vector<double> system;
std::vector<double> weighted_basis;
normal_system(basis, count, terms, weights, ridge, &system, &weighted_basis);
std::vector<double> right(static_cast<std::size_t>(terms) * columns, 0.0);
for (std::size_t row = 0u; row < count; ++row) {
for (int term = 0; term < terms; ++term) {
const double basis_value =
basis[row * static_cast<std::size_t>(terms) + static_cast<std::size_t>(term)];
if (basis_value == 0.0) {
continue;
}
for (std::size_t column = 0u; column < columns; ++column) {
right[static_cast<std::size_t>(term) * columns + column] +=
basis_value * weights[row] * values[row * columns + column];
}
}
}
lu_solve_real(&system, terms, &right, static_cast<int>(columns));
return right;
}
// --------------------------------------------------------- time alignment ---
// sofa_canonical._subsample_peak: parabolic peak of |values|.
double subsample_peak(const std::vector<double>& values) {
std::size_t index = 0u;
double magnitude = -1.0;
for (std::size_t position = 0u; position < values.size(); ++position) {
const double current = std::abs(values[position]);
if (current > magnitude) {
magnitude = current;
index = position;
}
}
if (index == 0u || index + 1u >= values.size()) {
return static_cast<double>(index);
}
const double previous = std::abs(values[index - 1u]);
const double current = std::abs(values[index]);
const double next = std::abs(values[index + 1u]);
const double denominator = previous - 2.0 * current + next;
double correction = 0.0;
if (std::abs(denominator) >= 1.0e-30) {
correction = 0.5 * (previous - next) / denominator;
}
correction = std::min(0.5, std::max(-0.5, correction));
return static_cast<double>(index) + correction;
}
struct AlignedHrtf {
std::vector<double> aligned; // [M,2,N]
std::vector<double> runtime_delay; // [M,2]
std::vector<double> embedded_removed; // [M,2]
std::string delay_source;
};
// sofa_canonical.time_align_hrtf: separate exactly one delay representation.
AlignedHrtf time_align_hrtf(const CanonicalHrtf& canonical) {
AlignedHrtf result;
const std::size_t measurements = canonical.measurements;
const std::size_t taps = canonical.taps;
result.aligned = canonical.hrir;
result.runtime_delay.assign(measurements * 2u, 0.0);
result.embedded_removed.assign(measurements * 2u, 0.0);
double maximum = 0.0;
for (const double value : canonical.delay_samples) {
maximum = std::max(maximum, std::abs(value));
}
if (maximum > 1.0e-12) {
result.runtime_delay = canonical.delay_samples;
result.delay_source = "Data.Delay (external; applied once at render time)";
return result;
}
std::size_t used_peak = 0u;
std::size_t retained = 0u;
std::vector<double> ear(taps, 0.0);
// hrir[measurement][channel] as a tap range.
auto ear_range = [&](std::size_t measurement, int channel) {
const std::size_t first = (measurement * 2u + static_cast<std::size_t>(channel)) * taps;
return std::make_pair(canonical.hrir.begin() + static_cast<std::ptrdiff_t>(first),
canonical.hrir.begin() + static_cast<std::ptrdiff_t>(first + taps));
};
for (std::size_t measurement = 0u; measurement < measurements; ++measurement) {
double peaks[2] = {0.0, 0.0};
for (int channel = 0; channel < 2; ++channel) {
const auto range = ear_range(measurement, channel);
std::copy(range.first, range.second, ear.begin());
peaks[channel] = subsample_peak(ear);
}
if (std::max(peaks[0], peaks[1]) > 2.0) {
++used_peak;
} else {
peaks[0] = 0.0;
peaks[1] = 0.0;
++retained;
}
for (int channel = 0; channel < 2; ++channel) {
const std::size_t ear_index = measurement * 2u + static_cast<std::size_t>(channel);
result.runtime_delay[ear_index] = peaks[channel];
result.embedded_removed[ear_index] = peaks[channel];
if (peaks[channel] != 0.0) {
const auto range = ear_range(measurement, channel);
std::copy(range.first, range.second, ear.begin());
const std::vector<double> shifted = shift_signal_fft(ear, -peaks[channel]);
std::copy(shifted.begin(), shifted.end(),
result.aligned.begin() + static_cast<std::ptrdiff_t>(ear_index * taps));
}
}
}
result.delay_source = "embedded Data.IR arrival separation: peak=" +
std::to_string(used_peak) +
", zero-origin embedded phase retained=" + std::to_string(retained) +
"; positive onset restored once at render time";
return result;
}
// ------------------------------------------------------------- pipeline -----
// canonical.shell_indices: the measurements on the shell nearest to radius_m.
} // namespace
std::vector<std::size_t> canonical_shell_indices(const CanonicalHrtf& canonical, double radius_m,
double* actual_radius_m) {
std::vector<double> shells;
for (const double radius : canonical.measurement_radius_m) {
const double rounded = round_half_even(radius, 9);
if (std::find(shells.begin(), shells.end(), rounded) == shells.end()) {
shells.push_back(rounded);
}
}
std::sort(shells.begin(), shells.end());
double shell = shells.empty() ? radius_m : shells.front();
double best = std::numeric_limits<double>::infinity();
for (const double candidate : shells) {
const double distance = std::abs(candidate - radius_m);
if (distance < best) {
best = distance;
shell = candidate;
}
}
std::vector<std::size_t> indices;
double total = 0.0;
for (std::size_t index = 0u; index < canonical.measurement_radius_m.size(); ++index) {
if (std::abs(canonical.measurement_radius_m[index] - shell) <= 5.0e-7) {
indices.push_back(index);
total += canonical.measurement_radius_m[index];
}
}
*actual_radius_m = indices.empty() ? shell : total / static_cast<double>(indices.size());
return indices;
}
namespace {
// _group_coincident: merge measurements that share a direction (keys are the
// directions rounded to ten decimals, grouped in sorted key order).
void group_coincident(const std::vector<double>& directions, std::size_t count,
const std::vector<Complex>& gains, std::size_t columns,
const std::vector<double>& delays, std::vector<double>* out_directions,
std::vector<Complex>* out_gains, std::vector<double>* out_delays) {
struct Key {
double value[3];
std::size_t first;
};
std::vector<Key> keys(count);
for (std::size_t index = 0u; index < count; ++index) {
for (int axis = 0; axis < 3; ++axis) {
keys[index].value[axis] =
round_half_even(directions[index * 3u + static_cast<std::size_t>(axis)], 10);
}
keys[index].first = index;
}
std::sort(keys.begin(), keys.end(), [](const Key& left, const Key& right) {
for (int axis = 0; axis < 3; ++axis) {
if (left.value[axis] != right.value[axis]) {
return left.value[axis] < right.value[axis];
}
}
return left.first < right.first;
});
std::vector<Key> unique;
std::vector<std::size_t> group_of(count, 0u);
for (const Key& key : keys) {
if (unique.empty() || unique.back().value[0] != key.value[0] ||
unique.back().value[1] != key.value[1] || unique.back().value[2] != key.value[2]) {
unique.push_back(key);
} else if (key.first < unique.back().first) {
unique.back().first = key.first;
}
group_of[key.first] = unique.size() - 1u;
}
const std::size_t groups = unique.size();
out_directions->assign(groups * 3u, 0.0);
out_gains->assign(groups * columns, Complex(0.0, 0.0));
out_delays->assign(groups * 2u, 0.0);
std::vector<double> counts(groups, 0.0);
for (std::size_t index = 0u; index < count; ++index) {
const std::size_t group = group_of[index];
++counts[group];
for (int axis = 0; axis < 3; ++axis) {
(*out_directions)[group * 3u + static_cast<std::size_t>(axis)] +=
directions[index * 3u + static_cast<std::size_t>(axis)];
}
for (std::size_t column = 0u; column < columns; ++column) {
(*out_gains)[group * columns + column] += gains[index * columns + column];
}
for (std::size_t ear = 0u; ear < 2u; ++ear) {
(*out_delays)[group * 2u + ear] += delays[index * 2u + ear];
}
}
for (std::size_t group = 0u; group < groups; ++group) {
for (std::size_t column = 0u; column < columns; ++column) {
(*out_gains)[group * columns + column] /= counts[group];
}
for (std::size_t ear = 0u; ear < 2u; ++ear) {
(*out_delays)[group * 2u + ear] /= counts[group];
}
}
}
std::string basename_of(const std::string& path) {
const std::size_t slash = path.find_last_of("/\\");
return slash == std::string::npos ? path : path.substr(slash + 1u);
}
// sofa_canonical._processing_label: the non-empty identifying attributes.
std::string processing_label(const SofaHrir& sofa) {
std::vector<std::string> parts;
const std::string candidates[4] = {sofa.database_name, sofa.title, sofa.listener_short_name,
sofa.comment};
for (const std::string& value : candidates) {
const std::size_t begin = value.find_first_not_of(" \t");
const std::size_t end = value.find_last_not_of(" \t");
if (begin == std::string::npos) {
continue;
}
const std::string trimmed = value.substr(begin, end - begin + 1u);
if (std::find(parts.begin(), parts.end(), trimmed) == parts.end()) {
parts.push_back(trimmed);
}
}
std::string label;
for (std::size_t index = 0u; index < parts.size(); ++index) {
if (index != 0u) {
label += " | ";
}
label += parts[index];
}
return label;
}
double minimum_of(const std::vector<double>& values) {
double result = std::numeric_limits<double>::infinity();
for (const double value : values) {
result = std::min(result, value);
}
return values.empty() ? 0.0 : result;
}
double maximum_of(const std::vector<double>& values) {
double result = -std::numeric_limits<double>::infinity();
for (const double value : values) {
result = std::max(result, value);
}
return values.empty() ? 0.0 : result;
}
} // namespace
Status canonicalize_sofa(const SofaHrir& sofa, CanonicalHrtf* out) {
if (out == nullptr) {
return field_fail(JOC_ERR_INVALID_ARGUMENT, "null canonical HRTF destination");
}
if (sofa.conventions != "SOFA") {
return field_fail(JOC_ERR_HRTF_UNSUPPORTED_CONVENTION, "Conventions must be SOFA");
}
if (sofa.sofa_conventions != "SimpleFreeFieldHRIR") {
return field_fail(JOC_ERR_HRTF_UNSUPPORTED_CONVENTION,
"unsupported SOFAConventions=" + sofa.sofa_conventions +
"; convert explicitly first");
}
if (sofa.convention_version != "0.4" && sofa.convention_version != "1.0" &&
sofa.convention_version != "1.1") {
return field_fail(JOC_ERR_HRTF_UNSUPPORTED_CONVENTION,
"unsupported SimpleFreeFieldHRIR version " + sofa.convention_version);
}
if (sofa.data_type != "FIR") {
return field_fail(JOC_ERR_HRTF_FORMAT, "DataType must be FIR");
}
const std::string room_type = lowered(sofa.room_type);
if (room_type != "free field" && room_type != "free-field" && room_type != "anechoic" &&
room_type != "hemi-anechoic") {
return field_fail(JOC_ERR_HRTF_FORMAT,
"RoomType must explicitly be free-field, got " + sofa.room_type);
}
const std::size_t measurements = sofa.ir_count;
const std::size_t taps = sofa.ir_length;
if (measurements == 0u || taps == 0u || sofa.ir.size() != measurements * 2u * taps) {
return field_fail(JOC_ERR_HRTF_FORMAT, "Data.IR must have shape [M,R=2,N]");
}
for (const double value : sofa.ir) {
if (!std::isfinite(value)) {
return field_fail(JOC_ERR_HRTF_FORMAT, "Data.IR contains non-finite values");
}
}
if (!std::isfinite(sofa.sample_rate) || sofa.sample_rate <= 0.0) {
return field_fail(JOC_ERR_HRTF_FORMAT,
"Data.SamplingRate must contain one positive finite value");
}
const std::string rate_units = lowered(sofa.sampling_rate_units);
if (rate_units != "hertz" && rate_units != "hz") {
return field_fail(JOC_ERR_HRTF_FORMAT,
"Data.SamplingRate Units must be hertz, got " + sofa.sampling_rate_units);
}
std::vector<double> delay(2u, 0.0);
for (std::size_t ear = 0u; ear < 2u; ++ear) {
const double value = sofa.delay[ear];
if (!std::isfinite(value)) {
return field_fail(JOC_ERR_HRTF_FORMAT, "Data.Delay must be finite");
}
if (value < -1.0e-9) {
return field_fail(JOC_ERR_HRTF_FORMAT,
"negative Data.Delay is outside the supported causal contract");
}
delay[ear] = std::max(0.0, value);
}
const std::vector<double> emitter_values(sofa.emitter_position,
sofa.emitter_position + 3);
std::vector<double> emitter;
Status status = convert_coordinates(emitter_values, 1u, sofa.emitter_position_coordinates.type,
sofa.emitter_position_coordinates.units, "EmitterPosition",
&emitter);
if (!status.ok()) {
return status;
}
for (const double value : emitter) {
if (std::abs(value) > 1.0e-9) {
return field_fail(JOC_ERR_HRTF_FORMAT,
"non-zero EmitterPosition needs a separate source-pose adapter");
}
}
const std::vector<double> position_values(sofa.listener_position,
sofa.listener_position + 3);
const std::vector<double> view_values(sofa.listener_view, sofa.listener_view + 3);
const std::vector<double> up_values(sofa.listener_up, sofa.listener_up + 3);
std::vector<double> listener_position;
std::vector<double> listener_view;
std::vector<double> listener_up_raw;
status = convert_coordinates(position_values, 1u, sofa.listener_position_coordinates.type,
sofa.listener_position_coordinates.units, "ListenerPosition",
&listener_position);
if (!status.ok()) {
return status;
}
status = convert_coordinates(view_values, 1u, sofa.listener_view_coordinates.type,
sofa.listener_view_coordinates.units, "ListenerView", &listener_view);
if (!status.ok()) {
return status;
}
// ListenerUp inherits ListenerView's Type/Units when it declares none.
const std::string up_type = sofa.listener_up_coordinates.type.empty() ? sofa.listener_view_coordinates.type
: sofa.listener_up_coordinates.type;
const std::string up_units = sofa.listener_up_coordinates.units.empty()
? sofa.listener_view_coordinates.units
: sofa.listener_up_coordinates.units;
status = convert_coordinates(up_values, 1u, up_type, up_units, "ListenerUp", &listener_up_raw);
if (!status.ok()) {
return status;
}
double forward[3] = {listener_view[0], listener_view[1], listener_view[2]};
if (!normalize3(forward)) {
return field_fail(JOC_ERR_HRTF_FORMAT, "ListenerView must be non-zero");
}
double left[3];
cross3(listener_up_raw.data(), forward, left);
if (!normalize3(left)) {
return field_fail(JOC_ERR_HRTF_FORMAT, "ListenerUp must not be parallel to ListenerView");
}
double up[3];
cross3(forward, left, up);
std::vector<double> source_world;
status = convert_coordinates(sofa.source_position, measurements, sofa.source_position_coordinates.type,
sofa.source_position_coordinates.units, "SourcePosition",
&source_world);
if (!status.ok()) {
return status;
}
if (source_world.size() != measurements * 3u) {
return field_fail(JOC_ERR_HRTF_FORMAT, "SourcePosition must have shape [M,C=3]");
}
std::vector<double> source_local(measurements * 3u, 0.0);
std::vector<double> radii(measurements, 0.0);
for (std::size_t row = 0u; row < measurements; ++row) {
const double relative[3] = {source_world[row * 3u] - listener_position[0],
source_world[row * 3u + 1u] - listener_position[1],
source_world[row * 3u + 2u] - listener_position[2]};
source_local[row * 3u] = dot3(relative, forward);
source_local[row * 3u + 1u] = dot3(relative, left);
source_local[row * 3u + 2u] = dot3(relative, up);
radii[row] = std::sqrt(source_local[row * 3u] * source_local[row * 3u] +
source_local[row * 3u + 1u] * source_local[row * 3u + 1u] +
source_local[row * 3u + 2u] * source_local[row * 3u + 2u]);
if (!std::isfinite(radii[row]) || radii[row] <= 1.0e-8) {
return field_fail(JOC_ERR_HRTF_FORMAT,
"every source measurement must have a positive radius");
}
}
// ReceiverPosition is [R=2,C=3,I]; the two receivers are converted separately
// so their units are validated the same way as every other coordinate row.
std::vector<double> receiver_cartesian(6u, 0.0);
for (std::size_t receiver = 0u; receiver < 2u; ++receiver) {
const std::vector<double> row(sofa.receiver_position + receiver * 3u,
sofa.receiver_position + receiver * 3u + 3u);
std::vector<double> converted;
status = convert_coordinates(row, 1u, sofa.receiver_position_coordinates.type,
sofa.receiver_position_coordinates.units, "ReceiverPosition",
&converted);
if (!status.ok()) {
return status;
}
for (int axis = 0; axis < 3; ++axis) {
receiver_cartesian[receiver * 3u + static_cast<std::size_t>(axis)] =
converted[static_cast<std::size_t>(axis)];
}
}
int left_index = 0;
int right_index = 1;
const double lateral = receiver_cartesian[1] - receiver_cartesian[4];
if (lateral <= 1.0e-5) {
if (lateral >= -1.0e-5) {
return field_fail(JOC_ERR_HRTF_FORMAT,
"ReceiverPosition does not identify one consistently-left and one "
"consistently-right receiver");
}
left_index = 1;
right_index = 0;
}
CanonicalHrtf canonical;
canonical.source_path = sofa.source_path;
canonical.source_sha256 = to_upper(sofa.source_sha256);
canonical.convention = sofa.sofa_conventions;
canonical.convention_version = sofa.convention_version;
canonical.processing_label = processing_label(sofa);
canonical.sample_rate_hz = sofa.sample_rate;
canonical.measurements = static_cast<std::uint32_t>(measurements);
canonical.taps = static_cast<std::uint32_t>(taps);
canonical.left_receiver_index = left_index;
canonical.right_receiver_index = right_index;
canonical.source_position_cartesian_m = source_local;
canonical.unit_directions.resize(measurements * 3u, 0.0);
for (std::size_t row = 0u; row < measurements; ++row) {
for (int axis = 0; axis < 3; ++axis) {
canonical.unit_directions[row * 3u + static_cast<std::size_t>(axis)] =
source_local[row * 3u + static_cast<std::size_t>(axis)] / radii[row];
}
}
canonical.measurement_radius_m = radii;
canonical.hrir.assign(measurements * 2u * taps, 0.0);
canonical.delay_samples.assign(measurements * 2u, 0.0);
for (std::size_t row = 0u; row < measurements; ++row) {
for (std::size_t ear = 0u; ear < 2u; ++ear) {
const std::size_t source_ear = ear == 0u ? static_cast<std::size_t>(left_index)
: static_cast<std::size_t>(right_index);
std::copy(sofa.ir.begin() +
static_cast<std::ptrdiff_t>((row * 2u + source_ear) * taps),
sofa.ir.begin() +
static_cast<std::ptrdiff_t>((row * 2u + source_ear + 1u) * taps),
canonical.hrir.begin() + static_cast<std::ptrdiff_t>((row * 2u + ear) * taps));
canonical.delay_samples[row * 2u + ear] = delay[source_ear];
}
}
*out = std::move(canonical);
return Status::success();
}
Status compile_canonical_field(const CanonicalHrtf& canonical, const CompileOptions& options,
Field* out) {
if (out == nullptr) {
return field_fail(JOC_ERR_INVALID_ARGUMENT, "null field destination");
}
if (options.order != kFieldOrder) {
return field_fail(JOC_ERR_HRTF_FORMAT, "the runtime HRTF field is fixed at fifth order");
}
if (!std::isfinite(canonical.sample_rate_hz) ||
std::abs(canonical.sample_rate_hz - kFieldSampleRateHz) > 1.0e-9) {
return field_fail(JOC_ERR_HRTF_FORMAT, "SOFA HRTF fields must be compiled at 48 kHz");
}
if (!std::isfinite(options.shell_radius_m) || options.shell_radius_m <= 0.0) {
return field_fail(JOC_ERR_HRTF_FORMAT, "shell_radius_m must be positive and finite");
}
if (!std::isfinite(options.projection_ridge) || options.projection_ridge < 0.0 ||
!std::isfinite(options.sh_ridge) || options.sh_ridge < 0.0) {
return field_fail(JOC_ERR_HRTF_FORMAT,
"compiler ridge values must be finite and non-negative");
}
if (canonical.measurements == 0u || canonical.taps == 0u) {
return field_fail(JOC_ERR_HRTF_FORMAT, "canonical HRTF is empty");
}
const std::size_t terms = static_cast<std::size_t>(kFieldTerms);
const std::size_t gains_per_direction = static_cast<std::size_t>(kParameterCount);
const std::size_t columns = static_cast<std::size_t>(kParameterCount);
const AlignedHrtf aligned = time_align_hrtf(canonical);
double shell_radius = options.shell_radius_m;
const std::vector<std::size_t> indices =
canonical_shell_indices(canonical, options.shell_radius_m, &shell_radius);
if (indices.size() < terms) {
return field_fail(JOC_ERR_HRTF_FORMAT,
"fifth-order SH needs at least 36 measurements on one shell, got " +
std::to_string(indices.size()));
}
const std::size_t selected = indices.size();
// Project the selected HRIRs onto the public analysis/gain/synthesis dictionary.
const Kernels& kernels = builtin_kernels();
const std::vector<double> dictionary = hybrid_gain_synthesis_dictionary(canonical.taps, kernels);
std::vector<double> system(columns * columns, 0.0);
for (std::size_t left = 0u; left < columns; ++left) {
for (std::size_t right = 0u; right < columns; ++right) {
double sum = 0.0;
for (std::size_t tap = 0u; tap < canonical.taps; ++tap) {
sum += dictionary[tap * columns + left] * dictionary[tap * columns + right];
}
system[left * columns + right] = sum;
}
}
double trace = 0.0;
for (std::size_t parameter = 0u; parameter < columns; ++parameter) {
trace += system[parameter * columns + parameter];
}
const double scale = trace / static_cast<double>(columns);
for (std::size_t parameter = 0u; parameter < columns; ++parameter) {
system[parameter * columns + parameter] += options.projection_ridge * scale;
}
// target[tap][measurement*2 + ear], the reference's reshape(-1, N).T.
const std::size_t target_columns = selected * 2u;
std::vector<double> target(canonical.taps * target_columns, 0.0);
for (std::size_t row = 0u; row < selected; ++row) {
for (std::size_t ear = 0u; ear < 2u; ++ear) {
const std::size_t column = row * 2u + ear;
for (std::size_t tap = 0u; tap < canonical.taps; ++tap) {
target[tap * target_columns + column] =
canonical.hrir[(indices[row] * 2u + ear) * canonical.taps + tap];
}
}
}
std::vector<double> parameters(columns * target_columns, 0.0);
for (std::size_t tap = 0u; tap < canonical.taps; ++tap) {
for (std::size_t parameter = 0u; parameter < columns; ++parameter) {
const double value = dictionary[tap * columns + parameter];
if (value == 0.0) {
continue;
}
for (std::size_t column = 0u; column < target_columns; ++column) {
parameters[parameter * target_columns + column] +=
value * target[tap * target_columns + column];
}
}
}
if (!lu_solve_real(&system, static_cast<int>(columns), &parameters,
static_cast<int>(target_columns))) {
return field_fail(JOC_ERR_HRTF_FORMAT, "HRTF projection system is singular");
}
// Hybrid gains [measurement][ear*77 + band] with the known embedded delay
// removed exactly once (parameters[parameter][measurement*2 + ear]).
std::vector<double> selected_removed(selected * 2u, 0.0);
std::vector<Complex> gains_by_direction(selected * gains_per_direction, Complex(0.0, 0.0));
for (std::size_t row = 0u; row < selected; ++row) {
for (std::size_t ear = 0u; ear < 2u; ++ear) {
const std::size_t column = row * 2u + ear;
const double removed = aligned.embedded_removed[indices[row] * 2u + ear];
selected_removed[column] = removed;
for (int band = 0; band < kHybridBands; ++band) {
const double real = parameters[static_cast<std::size_t>(2 * band) * target_columns + column];
const double imaginary =
parameters[static_cast<std::size_t>(2 * band + 1) * target_columns + column];
const double removal =
2.0 * kPi * removed * kBandCenters[band] / canonical.sample_rate_hz;
gains_by_direction[row * gains_per_direction + ear * kHybridBands +
static_cast<std::size_t>(band)] =
Complex(real, imaginary) * std::polar(1.0, removal);
}
}
}
std::vector<double> signal_to_noise(target_columns, 0.0);
double maximum_gain = 0.0;
for (const Complex value : gains_by_direction) {
maximum_gain = std::max(maximum_gain, std::abs(value));
}
for (std::size_t column = 0u; column < target_columns; ++column) {
double reference_energy = 0.0;
double error_energy = 0.0;
for (std::size_t tap = 0u; tap < canonical.taps; ++tap) {
double reconstructed = 0.0;
for (std::size_t parameter = 0u; parameter < columns; ++parameter) {
reconstructed += dictionary[tap * columns + parameter] *
parameters[parameter * target_columns + column];
}
const double value = target[tap * target_columns + column];
reference_energy += value * value;
const double error = value - reconstructed;
error_energy += error * error;
}
signal_to_noise[column] =
10.0 * std::log10(std::max(reference_energy, 1.0e-300) /
std::max(error_energy, 1.0e-300));
}
std::vector<double> directions;
std::vector<Complex> gains;
std::vector<double> runtime_delay;
std::vector<double> selected_directions(selected * 3u, 0.0);
std::vector<double> selected_delays(selected * 2u, 0.0);
for (std::size_t row = 0u; row < selected; ++row) {
for (int axis = 0; axis < 3; ++axis) {
selected_directions[row * 3u + static_cast<std::size_t>(axis)] =
canonical.unit_directions[indices[row] * 3u + static_cast<std::size_t>(axis)];
}
for (std::size_t ear = 0u; ear < 2u; ++ear) {
selected_delays[row * 2u + ear] = aligned.runtime_delay[indices[row] * 2u + ear];
}
}
group_coincident(selected_directions, selected, gains_by_direction, gains_per_direction,
selected_delays, &directions, &gains, &runtime_delay);
const std::size_t unique = directions.size() / 3u;
if (unique < terms) {
return field_fail(JOC_ERR_HRTF_FORMAT,
"coincident-direction merging left fewer than 36 directions");
}
std::vector<double> basis;
real_spherical_harmonics(directions, unique, options.order, &basis);
const std::vector<double> weights = spherical_voronoi_weights(directions, unique);
const std::vector<Complex> coefficients = fit_complex(
basis, unique, kFieldTerms, weights, options.sh_ridge, gains, gains_per_direction);
const std::vector<double> delay_coefficients =
fit_real(basis, unique, kFieldTerms, weights, options.sh_ridge, runtime_delay, 2u);
// Reconstruction error of the reported fit.
std::vector<double> relative_error;
std::vector<double> magnitude_error_db;
std::vector<double> delay_error;
relative_error.reserve(unique * kHybridBands);
magnitude_error_db.reserve(unique * kHybridBands);
delay_error.reserve(unique * 2u);
for (std::size_t row = 0u; row < unique; ++row) {
for (std::size_t ear = 0u; ear < 2u; ++ear) {
double reconstructed_delay = 0.0;
for (std::size_t term = 0u; term < terms; ++term) {
reconstructed_delay += basis[row * terms + term] * delay_coefficients[term * 2u + ear];
}
const double reference_delay = runtime_delay[row * 2u + ear];
delay_error.push_back(reconstructed_delay - reference_delay);
for (int band = 0; band < kHybridBands; ++band) {
Complex reconstructed_aligned(0.0, 0.0);
for (std::size_t term = 0u; term < terms; ++term) {
reconstructed_aligned +=
basis[row * terms + term] *
coefficients[term * gains_per_direction + ear * kHybridBands +
static_cast<std::size_t>(band)];
}
const Complex reference =
gains[row * gains_per_direction + ear * kHybridBands +
static_cast<std::size_t>(band)] *
std::polar(1.0, -2.0 * kPi * reference_delay * kBandCenters[band] /
canonical.sample_rate_hz);
const Complex reconstructed =
reconstructed_aligned *
std::polar(1.0, -2.0 * kPi * reconstructed_delay * kBandCenters[band] /
canonical.sample_rate_hz);
const double magnitude_reference = std::max(std::abs(reference), 1.0e-12);
relative_error.push_back(std::abs(reconstructed - reference) / magnitude_reference);
magnitude_error_db.push_back(
std::abs(20.0 * std::log10(std::max(std::abs(reconstructed), 1.0e-12)) -
20.0 * std::log10(magnitude_reference)));
}
}
}
double delay_square_sum = 0.0;
double delay_maximum = 0.0;
for (const double value : delay_error) {
delay_square_sum += value * value;
delay_maximum = std::max(delay_maximum, std::abs(value));
}
// Delay bounds per ear, and the four arrays the cache stores.
std::vector<double> delay_bounds(4u, 0.0);
for (std::size_t ear = 0u; ear < 2u; ++ear) {
double minimum = std::numeric_limits<double>::infinity();
double maximum = -std::numeric_limits<double>::infinity();
for (std::size_t row = 0u; row < unique; ++row) {
minimum = std::min(minimum, runtime_delay[row * 2u + ear]);
maximum = std::max(maximum, runtime_delay[row * 2u + ear]);
}
delay_bounds[ear * 2u] = minimum;
delay_bounds[ear * 2u + 1u] = maximum;
}
std::vector<double> flat_coefficients(terms * 2u * kHybridBands * 2u, 0.0);
for (std::size_t term = 0u; term < terms; ++term) {
for (std::size_t ear = 0u; ear < 2u; ++ear) {
for (int band = 0; band < kHybridBands; ++band) {
const Complex value =
coefficients[term * gains_per_direction + ear * kHybridBands +
static_cast<std::size_t>(band)];
const std::size_t offset =
((term * 2u + ear) * kHybridBands + static_cast<std::size_t>(band)) * 2u;
flat_coefficients[offset] = value.real();
flat_coefficients[offset + 1u] = value.imag();
}
}
}
Field field;
field.source_sha256 = to_upper(canonical.source_sha256);
field.source_display_name = basename_of(canonical.source_path);
field.measurement_radius_m = shell_radius;
field.order = options.order;
field.projection_ridge = options.projection_ridge;
field.spherical_harmonic_ridge = options.sh_ridge;
field.compiler_version = kCompilerVersion;
field.phase_policy_version = kPhasePolicyVersion;
field.sh_convention = kShConvention;
field.filterbank_json = filterbank_fingerprint_json();
field.delay_source = aligned.delay_source;
field.band_centers_hz.assign(kBandCenters, kBandCenters + kHybridBands);
field.coefficients = std::move(flat_coefficients);
field.delay_coefficients = std::move(delay_coefficients);
field.delay_bounds = std::move(delay_bounds);
field.cache_key = compiled_hrtf_cache_key(field.source_sha256, canonical.sample_rate_hz,
shell_radius, options.order,
options.projection_ridge, options.sh_ridge);
field.fit_report_json = json_object({
{"complex_relative_error_median", json_number(percentile(relative_error, 50.0))},
{"complex_relative_error_p95", json_number(percentile(relative_error, 95.0))},
{"delay_error_samples_max", json_number(delay_maximum)},
{"delay_error_samples_rms",
json_number(std::sqrt(delay_square_sum / static_cast<double>(delay_error.size())))},
{"delay_source", json_escape(aligned.delay_source)},
{"format", json_escape("SOFA FIR -> public 64-QMF/77-hybrid -> ACN/N3D real SH")},
{"input_measurements", std::to_string(selected)},
{"magnitude_error_db_median", json_number(percentile(magnitude_error_db, 50.0))},
{"magnitude_error_db_p95", json_number(percentile(magnitude_error_db, 95.0))},
{"order", std::to_string(options.order)},
{"phase_policy_version", json_escape(kPhasePolicyVersion)},
{"precision", json_escape("float64/complex128")},
{"projection",
json_object({
{"dictionary_shape",
json_array({std::to_string(canonical.taps), std::to_string(kParameterCount)})},
{"embedded_delay_samples_max", json_number(maximum_of(selected_removed))},
{"embedded_delay_samples_min", json_number(minimum_of(selected_removed))},
{"fir_reconstruction_snr_db_median", json_number(percentile(signal_to_noise, 50.0))},
{"fir_reconstruction_snr_db_min", json_number(minimum_of(signal_to_noise))},
{"fir_reconstruction_snr_db_p05", json_number(percentile(signal_to_noise, 5.0))},
{"maximum_absolute_hybrid_gain", json_number(maximum_gain)},
{"method", json_escape("regularized public analysis/gain/synthesis dictionary")},
{"precision", json_escape("float64/complex128")},
{"real_parameters", std::to_string(kParameterCount)},
{"ridge", json_number(options.projection_ridge)},
})},
{"shell_radius_m", json_number(shell_radius)},
{"spherical_harmonic_ridge", json_number(options.sh_ridge)},
{"terms", std::to_string(kFieldTerms)},
{"unique_directions", std::to_string(unique)},
});
field.payload_sha256 = field_payload_sha256(field);
field.metadata_json = json_object({
{"cache_key", json_escape(field.cache_key)},
{"cache_key_version", "1"},
{"cache_schema", json_escape(std::string(kCacheSchema))},
{"compiler_version", json_escape(field.compiler_version)},
{"delay_source", json_escape(field.delay_source)},
{"filterbank", field.filterbank_json},
{"fit_report", field.fit_report_json},
{"format_version", std::to_string(kFormatVersion)},
{"magic", json_escape(std::string(kMagic))},
{"measurement_radius_m", json_number(field.measurement_radius_m)},
{"order", std::to_string(field.order)},
{"payload_sha256", json_escape(field.payload_sha256)},
{"phase_policy_version", json_escape(field.phase_policy_version)},
{"projection_ridge", json_number(field.projection_ridge)},
{"sample_rate_hz", json_number(canonical.sample_rate_hz)},
{"sh_convention", json_escape(field.sh_convention)},
{"source_display_name", json_escape(field.source_display_name)},
{"source_sha256", json_escape(field.source_sha256)},
{"spherical_harmonic_ridge", json_number(field.spherical_harmonic_ridge)},
});
*out = std::move(field);
return Status::success();
}
void shell_directions_and_weights_for_check(const SofaHrir& sofa, double radius_m,
std::vector<double>* directions,
std::vector<double>* weights) {
CanonicalHrtf canonical;
if (!canonicalize_sofa(sofa, &canonical).ok()) {
return;
}
double actual = radius_m;
const std::vector<std::size_t> indices = canonical_shell_indices(canonical, radius_m, &actual);
directions->assign(indices.size() * 3u, 0.0);
for (std::size_t row = 0u; row < indices.size(); ++row) {
for (int axis = 0; axis < 3; ++axis) {
(*directions)[row * 3u + static_cast<std::size_t>(axis)] =
canonical.unit_directions[indices[row] * 3u + static_cast<std::size_t>(axis)];
}
}
*weights = spherical_voronoi_weights(*directions, indices.size());
}
Status compile_sofa_field(const SofaHrir& sofa, const CompileOptions& options, Field* out) {
CanonicalHrtf canonical;
const Status status = canonicalize_sofa(sofa, &canonical);
if (!status.ok()) {
return status;
}
return compile_canonical_field(canonical, options, out);
}
} // namespace joc::hrtf