#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 #include #include #include 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"); } // Source stems reconstruct the mix, and muting leaves diagnostic stems alive. auto stem_config=config(); stem_config.dual_exhaust=true; stem_config.valve_contact_enabled=true; OutdoorAudio stems(44100,stem_config), muted(44100,stem_config); for(std::size_t j=0;j<4;++j) muted.set_source_enabled(j,false); for(int i=0;i<12000;++i) { const float x=.01f*std::sin(i*.07f); const auto mix=stems.process(x,x*.4f,-x*.2f,x*.1f); auto sum=stems.tail_frame(); for(const auto& source:stems.source_frames()) { sum.left+=source.rendered.left; sum.right+=source.rendered.right; } check(std::abs(sum.left-mix.left)<1e-6f && std::abs(sum.right-mix.right)<1e-6f, "source stems do not reconstruct rendered mix"); const auto silent=muted.process(x,x*.4f,-x*.2f,x*.1f); check(silent.left==0 && silent.right==0,"muted sources feed listening mix or reverb"); check(muted.source_frames()[0].rendered.left==stems.source_frames()[0].rendered.left, "muting changes diagnostic source history"); } 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"; }