Add turbocharged twin scenario and cross-platform test harness

This commit is contained in:
2026-10-23 02:27:03 +02:00
parent a859740a95
commit 589d22aa5e
63 changed files with 4903 additions and 771 deletions

171
src/outdoor_audio.cpp Normal file
View File

@@ -0,0 +1,171 @@
#include "FluidSim/Constants.hpp"
#include "FluidSim/OutdoorAudio.hpp"
#include <algorithm>
#include <bit>
#include <cmath>
#include <stdexcept>
namespace fluidsim::port {
namespace {
Vec3 sub(Vec3 a, Vec3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
float length(Vec3 a) { return std::sqrt(a.x * a.x + a.y * a.y + a.z * a.z); }
float distance(Vec3 a, Vec3 b) { return length(sub(a, b)); }
AudioFrame add(AudioFrame a, AudioFrame b) { return {a.left + b.left, a.right + b.right}; }
} // namespace
const char *scene_name(OutdoorScene scene) {
switch (scene) {
case OutdoorScene::Dry:
return "Dry";
case OutdoorScene::Field:
return "Open field";
case OutdoorScene::Wall:
return "Nearby wall";
case OutdoorScene::Woodland:
return "Woodland";
}
return "Unknown";
}
float OutdoorAudio::LowPass::process(float x) {
const float y = b0 * x + z1;
z1 = b1 * x - a1 * y + z2;
z2 = b2 * x - a2 * y;
if (std::abs(z1) < 1e-25f)
z1 = 0;
if (std::abs(z2) < 1e-25f)
z2 = 0;
return y;
}
OutdoorAudio::OutdoorAudio(int rate, OutdoorAudioConfig config) : rate_(rate), config_(config) {
if (rate < 8000 || !std::isfinite(config.sound_speed) || config.sound_speed <= 0 ||
!std::isfinite(config.monitor_cutoff_hz) || config.monitor_cutoff_hz <= 0 ||
!std::isfinite(config.pressure_gain) || config.pressure_gain < 0)
throw std::invalid_argument("invalid outdoor audio configuration");
const float norm = length(config_.listener_right);
if (!std::isfinite(norm) || norm < 1e-6f)
throw std::invalid_argument("invalid listener right vector");
config_.listener_right = {config_.listener_right.x / norm, config_.listener_right.y / norm,
config_.listener_right.z / norm};
configure_emitter(emitters_[0], config_.intake_position);
configure_emitter(emitters_[1], config_.exhaust_position);
constexpr std::array<int, 4> lengths{1499, 2111, 2633, 3251};
for (std::size_t i = 0; i < tail_.size(); ++i) {
auto &line = tail_[i];
line.buffer.resize(std::max(1, int(std::lround(lengths[i] * rate_ / 44100.0))));
line.gain = std::pow(10.0f, -3.0f * (float(line.buffer.size()) / rate_) / 0.35f);
}
scene_alpha_ = 1 - std::exp(-1.0f / (rate_ * .02f));
tail_alpha_ = 1 - std::exp(-2 * pi * 2500 / rate_);
}
void OutdoorAudio::configure_emitter(Emitter &emitter, Vec3 source) {
emitter.filtered.resize(std::bit_ceil(std::size_t(rate_)));
emitter.raw.resize(emitter.filtered.size()); // One second, validated path range.
const auto ear = [&](float sign) {
return Vec3{config_.listener.x + sign * config_.listener_right.x * config_.ear_spacing * .5f,
config_.listener.y + sign * config_.listener_right.y * config_.ear_spacing * .5f,
config_.listener.z + sign * config_.listener_right.z * config_.ear_spacing * .5f};
};
const auto path = [&](Vec3 image, float coefficient, int group, float cutoff, float extra = 0,
float source_distance = 1) {
Path p;
p.group = group;
const float dl = std::max(distance(image, ear(-1)), .1f), dr = std::max(distance(image, ear(1)), .1f);
const float sl = (dl + extra) * rate_ / config_.sound_speed,
sr = (dr + extra) * rate_ / config_.sound_speed;
if (!std::isfinite(sl) || !std::isfinite(sr) || sl >= rate_ - 2 || sr >= rate_ - 2)
throw std::invalid_argument("outdoor propagation path exceeds one second");
p.delay_left = std::size_t(sl);
p.delay_right = std::size_t(sr);
p.fraction_left = sl - p.delay_left;
p.fraction_right = sr - p.delay_right;
p.gain_left = coefficient / (dl * source_distance);
p.gain_right = coefficient / (dr * source_distance);
p.alpha = cutoff > 0 ? 1 - std::exp(-2 * pi * cutoff / rate_) : 1;
emitter.paths.push_back(p);
};
path(source, 1, 0, 0);
path({source.x, 2 * config_.ground_y - source.y, source.z}, config_.ground_reflection, 1, 1800);
path({2 * config_.wall_x - source.x, source.y, source.z}, config_.wall_reflection, 2, 4500);
// Single-bounce point scatterers: geometric paths, empirical scattering strengths.
for (Vec3 tree :
std::array<Vec3, 6>{{{-4, 1, 3}, {3, 1, 5}, {5, 1, -2}, {-6, 2, -3}, {-2, 1, 7}, {6, 2, 6}}}) {
const float first = std::max(distance(source, tree), .1f);
path(tree, .35f, 3, 2400, first, first);
}
const float cutoff = std::min(config_.monitor_cutoff_hz, rate_ * .45f);
const float w = 2 * pi * cutoff / rate_, cs = std::cos(w), alpha = std::sin(w) / std::sqrt(2.0f),
a0 = 1 + alpha;
emitter.filter = {(1 - cs) * .5f / a0, (1 - cs) / a0, (1 - cs) * .5f / a0, -2 * cs / a0,
(1 - alpha) / a0};
}
AudioFrame OutdoorAudio::render(Emitter &e, float flow, AudioFrame &dry, AudioFrame &raw) {
const float pressure = (flow - e.previous_flow) * rate_ * config_.pressure_gain / (4 * pi);
e.previous_flow = flow;
const float filtered = e.filter.process(pressure);
e.raw[e.position] = pressure;
e.filtered[e.position] = monitor_filter_ ? filtered : pressure;
const auto read = [&](const std::vector<float> &data, std::size_t delay, float fraction) {
const auto i = (e.position + data.size() - delay) & (data.size() - 1);
const auto j = (i + data.size() - 1) & (data.size() - 1);
return data[i] + (data[j] - data[i]) * fraction;
};
AudioFrame result{};
for (auto &p : e.paths) {
const float l = read(e.filtered, p.delay_left, p.fraction_left) * p.gain_left;
const float r = read(e.filtered, p.delay_right, p.fraction_right) * p.gain_right;
p.state_left += p.alpha * (l - p.state_left);
p.state_right += p.alpha * (r - p.state_right);
if (std::abs(p.state_left) < 1e-25f)
p.state_left = 0;
if (std::abs(p.state_right) < 1e-25f)
p.state_right = 0;
result.left += p.state_left * weights_[p.group];
result.right += p.state_right * weights_[p.group];
if (p.group == 0) {
dry = add(dry, {l, r});
raw = add(raw, {read(e.raw, p.delay_left, p.fraction_left) * p.gain_left,
read(e.raw, p.delay_right, p.fraction_right) * p.gain_right});
}
}
e.position = (e.position + 1) & (e.filtered.size() - 1);
return result;
}
AudioFrame OutdoorAudio::diffuse(float input) {
std::array<float, 4> values{};
float sum = 0;
for (std::size_t i = 0; i < 4; ++i) {
auto &line = tail_[i];
values[i] = line.buffer[line.position];
sum += values[i];
}
for (std::size_t i = 0; i < 4; ++i) {
auto &line = tail_[i];
const float scattered = values[i] - .5f * sum;
line.state += tail_alpha_ * (scattered - line.state);
if (std::abs(line.state) < 1e-25f)
line.state = 0;
line.buffer[line.position] = line.gain * line.state + .5f * input;
line.position = (line.position + 1) % line.buffer.size();
}
return {(values[0] + values[2]) * .35f, (values[1] + values[3]) * .35f};
}
AudioFrame OutdoorAudio::process(float intake, float exhaust) {
if (!std::isfinite(intake) || !std::isfinite(exhaust))
throw std::runtime_error("nonfinite acoustic source flow");
const bool wet = scene_ != OutdoorScene::Dry;
const std::array<float, 4> target{1, wet ? 1.f : 0.f, scene_ == OutdoorScene::Wall ? 1.f : 0.f,
scene_ == OutdoorScene::Woodland ? 1.f : 0.f};
for (std::size_t i = 0; i < 4; ++i)
weights_[i] += (target[i] - weights_[i]) * scene_alpha_;
const float target_tail =
scene_ == OutdoorScene::Wall
? .14f
: (scene_ == OutdoorScene::Woodland ? .25f : (scene_ == OutdoorScene::Field ? .025f : 0));
tail_mix_ += (target_tail - tail_mix_) * scene_alpha_;
last_dry_ = {};
last_raw_ = {};
const auto a = render(emitters_[0], intake, last_dry_, last_raw_);
const auto b = render(emitters_[1], exhaust, last_dry_, last_raw_);
const auto tail = diffuse((last_dry_.left + last_dry_.right) * .5f);
return {a.left + b.left + tail.left * tail_mix_, a.right + b.right + tail.right * tail_mix_};
}
} // namespace fluidsim::port