Migrate 32 engine definitions into validated startup-loaded TOML files and separate configuration and snapshots from scenario assembly. Add camera-based stereo propagation from shared pipe geometry, preserve exhaust character, tune carburetor mixtures and idle, use ignition cuts for carb engines, and support returning to the scenario menu. Validation: full Windows build and all 14 CTest checks passed.
198 lines
9.2 KiB
C++
198 lines
9.2 KiB
C++
#include "FluidSim/OutdoorAudio.hpp"
|
|
#include "FluidSim/ReedValve.hpp"
|
|
#include "FluidSim/Scenario.hpp"
|
|
#include "FluidSim/ScenarioGeometry.hpp"
|
|
#include "FluidSim/DesktopScenario.hpp"
|
|
#include "FluidSim/ScenarioCatalog.hpp"
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <iostream>
|
|
#include <stdexcept>
|
|
using namespace fluidsim::port;
|
|
namespace {
|
|
void check(bool c, const char *m) {
|
|
if (!c)
|
|
throw std::runtime_error(m);
|
|
}
|
|
OutdoorAudioConfig config() {
|
|
OutdoorAudioConfig c;
|
|
c.exhaust_position = {0, 1, 0};
|
|
c.listener = {0, 1, 3};
|
|
c.ear_spacing = 0;
|
|
c.wall_x = -2;
|
|
return c;
|
|
}
|
|
} // namespace
|
|
int main() {
|
|
// Geometric reflections must not arrive before the direct path or before
|
|
// the ground image's path length, even with the diffuse network enabled.
|
|
OutdoorAudio dry(44100, config()), wall(44100, config());
|
|
dry.set_scene(OutdoorScene::Dry);
|
|
dry.set_monitor_filter(false);
|
|
wall.set_monitor_filter(false);
|
|
for (int i = 0; i < 4410; i++) {
|
|
dry.process(0, 0);
|
|
wall.process(0, 0);
|
|
}
|
|
int first = -1, first_reflection = -1;
|
|
double tail_energy = 0, late_energy = 0;
|
|
float peak = 0;
|
|
for (int i = 0; i < 88200; i++) {
|
|
const auto d = dry.process(0, .001f), w = wall.process(0, .001f);
|
|
if (first < 0 && std::abs(d.left) > 1e-9)
|
|
first = i;
|
|
if (first_reflection < 0 && std::abs(w.left - d.left) > 1e-9)
|
|
first_reflection = i;
|
|
peak = std::max(peak, std::abs(w.left));
|
|
if (i > 2000 && i < 10000)
|
|
tail_energy += w.left * w.left + w.right * w.right;
|
|
if (i > 60000)
|
|
late_energy += w.left * w.left + w.right * w.right;
|
|
check(std::isfinite(w.left) && std::isfinite(w.right), "unstable outdoor tail");
|
|
}
|
|
check(first == 385, "direct fractional propagation delay is wrong");
|
|
check(first_reflection >= 463 && first_reflection <= 465, "ground image reflection time is wrong");
|
|
check(tail_energy > 0 && late_energy < tail_energy * 1e-6, "outdoor tail does not decay");
|
|
check(peak < 1, "impulse reflection exceeds device range");
|
|
|
|
OutdoorAudio filtered(44100, config()), unfiltered(44100, config()), doubled(44100, config());
|
|
for (auto *p : {&filtered, &unfiltered, &doubled})
|
|
p->set_scene(OutdoorScene::Dry);
|
|
unfiltered.set_monitor_filter(false);
|
|
float prevf = 0, prevu = 0, maxf = 0, maxu = 0;
|
|
for (int i = 0; i < 1000; i++) {
|
|
const float q = i == 0 ? .001f : 0;
|
|
auto f = filtered.process(0, q), u = unfiltered.process(0, q), twice = doubled.process(0, 2 * q);
|
|
maxf = std::max(maxf, std::abs(f.left - prevf));
|
|
maxu = std::max(maxu, std::abs(u.left - prevu));
|
|
prevf = f.left;
|
|
prevu = u.left;
|
|
check(std::abs(twice.left - 2 * f.left) < 1e-7, "monitoring path is nonlinear");
|
|
}
|
|
check(maxf < .6f * maxu, "monitor bandwidth fails to reduce adjacent-sample impulse steps");
|
|
|
|
auto near_config = config(), far_config = config();
|
|
near_config.listener.z = 1;
|
|
far_config.listener.z = 2;
|
|
OutdoorAudio near(44100, near_config), far(44100, far_config);
|
|
near.set_scene(OutdoorScene::Dry);
|
|
far.set_scene(OutdoorScene::Dry);
|
|
near.set_monitor_filter(false);
|
|
far.set_monitor_filter(false);
|
|
double sum_near = 0, sum_far = 0;
|
|
for (int i = 0; i < 1000; i++) {
|
|
sum_near += near.process(0, .001f).left;
|
|
sum_far += far.process(0, .001f).left;
|
|
}
|
|
check(std::abs(sum_near - 2 * sum_far) < 1e-7, "direct spreading does not follow inverse distance");
|
|
|
|
auto stereo_config = config();
|
|
stereo_config.ear_spacing = .18f;
|
|
stereo_config.exhaust_position.x = 1;
|
|
OutdoorAudio stereo(44100, stereo_config);
|
|
stereo.set_scene(OutdoorScene::Dry);
|
|
stereo.set_monitor_filter(false);
|
|
int left = -1, right = -1;
|
|
for (int i = 0; i < 1000; i++) {
|
|
auto a = stereo.process(0, .001f);
|
|
if (left < 0 && std::abs(a.left) > 1e-9)
|
|
left = i;
|
|
if (right < 0 && std::abs(a.right) > 1e-9)
|
|
right = i;
|
|
}
|
|
check(right < left, "source on right does not reach right ear first");
|
|
|
|
// A moved listener must converge to the same propagation as one created
|
|
// there, including the reversed ear orientation and the reflected field.
|
|
auto moved_config=stereo_config;
|
|
moved_config.listener={-.5f,1.5f,4};
|
|
moved_config.listener_right={-1,0,0};
|
|
OutdoorAudio moving(44100,stereo_config),destination(44100,moved_config);
|
|
moving.set_listener({moved_config.listener,moved_config.listener_right});
|
|
for(auto* p:{&moving,&destination})p->set_monitor_filter(false);
|
|
for(int i=0;i<44100;++i) {moving.process(0,0);destination.process(0,0);}
|
|
for(int i=0;i<6000;++i) {
|
|
const auto a=moving.process(0,.001f),b=destination.process(0,.001f);
|
|
check(std::abs(a.left-b.left)<2e-7f && std::abs(a.right-b.right)<2e-7f,
|
|
"moving listener does not update direct/reflected stereo paths");
|
|
}
|
|
// Exercise rapid pose changes while a wet tail is still ringing.
|
|
for(int i=0;i<22050;++i) {
|
|
if(i%735==0) {
|
|
const float angle=i*.0002f;
|
|
moving.set_listener({{3*std::sin(angle),1,3*std::cos(angle)},
|
|
{std::cos(angle),0,-std::sin(angle)}});
|
|
}
|
|
const auto a=moving.process(0,.001f);
|
|
check(std::isfinite(a.left) && std::isfinite(a.right) &&
|
|
std::abs(a.left)<1 && std::abs(a.right)<1,"moving wet paths became unstable");
|
|
}
|
|
|
|
// Source relocation must use geometry without resetting audio state.
|
|
auto source_config=config();source_config.exhaust_position={2,1,-1};
|
|
OutdoorAudio relocated(44100,config()),fixed_source(44100,source_config);
|
|
relocated.set_source_positions({source_config.intake_position,source_config.exhaust_position,
|
|
source_config.second_exhaust_position});
|
|
for(int i=0;i<44100;++i) {relocated.process(0,0);fixed_source.process(0,0);}
|
|
for(int i=0;i<3000;++i) {
|
|
const auto a=relocated.process(0,.001f),b=fixed_source.process(0,.001f);
|
|
check(std::abs(a.left-b.left)<2e-7f && std::abs(a.right-b.right)<2e-7f,
|
|
"relocated mouth does not match world source position");
|
|
}
|
|
ScenarioSnapshot geometry;
|
|
geometry.two_stroke=true;
|
|
auto mouths=scenario_sound_sources(geometry);
|
|
check(mouths[0].x==-1.05f && mouths[1].x==1.45f && mouths[1].y==.23f,
|
|
"two-stroke mouths are not on the drawn pipe ends");
|
|
geometry.two_stroke=false;geometry.cylinder_count=8;geometry.layout=EngineLayout::V;
|
|
geometry.v8_headers=true;geometry.dual_exhaust=true;
|
|
mouths=scenario_sound_sources(geometry);
|
|
check(mouths[1].x==2.94f && mouths[1].z==-.39f && mouths[2].z==.39f,
|
|
"dual V8 outlets are not at their separate tailpipe mouths");
|
|
geometry.v8_headers=false;geometry.dual_exhaust=false;geometry.silencers=true;
|
|
geometry.cylinder_count=4;geometry.layout=EngineLayout::Inline;
|
|
const auto& e=geometry.exhaust_config;
|
|
mouths=scenario_sound_sources(geometry);
|
|
check(std::abs(mouths[1].x-(.74f+e.inlet_length+e.resonator.length+e.connecting_length+
|
|
e.muffler.length+e.outlet_length))<1e-6f,
|
|
"silenced exhaust source does not follow the configured pipe length");
|
|
// Crossing a reflecting plane must not turn its virtual image into a
|
|
// source that arrives before the actual pipe mouth.
|
|
auto behind_config=config();behind_config.listener={-4,1,0};
|
|
OutdoorAudio behind_wall(44100,behind_config);
|
|
behind_wall.set_monitor_filter(false);
|
|
for(int i=0;i<4410;++i)behind_wall.process(0,0);
|
|
int first_behind=-1;
|
|
for(int i=0;i<1000;++i) {
|
|
const auto a=behind_wall.process(0,.001f);
|
|
if(first_behind<0 && std::abs(a.left)>1e-9f)first_behind=i;
|
|
}
|
|
check(first_behind==514,"reflection crosses wall and precedes the physical source");
|
|
|
|
for(const auto& example:scenario_examples()) {
|
|
DesktopScenario scenario(example.kind,example.two,example.exhaust,example.junctions,example.engine);
|
|
scenario.set_listener({{2,1,3},{1,0,0}});
|
|
const auto snapshot=scenario.snapshot();
|
|
const auto sources=scenario_sound_sources(snapshot);
|
|
for(auto source:sources)
|
|
check(std::isfinite(source.x) && std::isfinite(source.y) && std::isfinite(source.z),
|
|
"catalog scenario has an invalid sound source");
|
|
const auto frame=scenario.process();
|
|
check(std::isfinite(frame.left) && std::isfinite(frame.right),"catalog listener wiring failed");
|
|
}
|
|
|
|
ReedValve reed;
|
|
for (int i = 0; i < 10000; i++)
|
|
reed.step(1.f / 44100, 100);
|
|
const double inertia = reed.width * std::pow(reed.thickness, 3) / 12;
|
|
const double expected = 100 * reed.width * std::pow(reed.length, 4) / (8 * reed.youngs_modulus * inertia);
|
|
check(std::abs(reed.lift() - expected) < expected * .001,
|
|
"reed static compliance differs from cantilever equation");
|
|
for (int i = 0; i < 1000; i++)
|
|
reed.step(1.f / 44100, -1000);
|
|
check(reed.lift() == 0, "reed does not seat under reverse pressure");
|
|
std::cout << "Outdoor audio tests passed: direct=" << first << " samples, ground=" << first_reflection
|
|
<< " samples, filtered step ratio=" << maxf / maxu << ", stereo lead=" << left - right
|
|
<< " samples\n";
|
|
}
|