diff --git a/CMakeLists.txt b/CMakeLists.txt index 7e82643..36df1f2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,7 @@ ccp_add_library(CarbonAudio SHARED src/AudGameObjResource.cpp src/AudGeometry.cpp src/AudGeometry_Blue.cpp + src/AudObstructionOcclusion.cpp src/SpatialAudioSettings.cpp src/audio2.cpp src/AudGameObjResource_Blue.cpp @@ -83,6 +84,7 @@ target_sources(CarbonAudio PRIVATE src/AudUIPlayer.h src/AudMusicPlayer.h src/AudGeometry.h + src/AudObstructionOcclusion.h src/SpatialAudioSettings.h src/autoversion.h src/DebugUtilities.h @@ -187,6 +189,14 @@ if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) RUNTIME DESTINATION bin/${CCP_PLATFORM}/${CCP_ARCHITECTURE}/${CCP_TOOLSET} ) + if(MSVC) + install( + FILES $ + CONFIGURATIONS ${CMAKE_CONFIGURATION_TYPES} + DESTINATION bin/${CCP_PLATFORM}/${CCP_ARCHITECTURE}/${CCP_TOOLSET} + OPTIONAL + ) + endif() # Generate target configuration for the monolith configure_ccp_vendor_config_file( TARGET CarbonAudio diff --git a/InstallToMonolith.vcxproj b/InstallToMonolith.vcxproj index 763d566..04fe8fc 100644 --- a/InstallToMonolith.vcxproj +++ b/InstallToMonolith.vcxproj @@ -20,15 +20,12 @@ - $(CCP_EVE_PERFORCE_BRANCH_PATH) $([MSBuild]::GetRegistryValue('HKEY_CURRENT_USER\Environment', 'CCP_EVE_PERFORCE_BRANCH_PATH')) - $(MonolithBranchRoot.Replace('\', '/'))/vendor/github.com/ccpgames/carbon-audio/develop + $(MonolithBranchRoot.Replace('\', '/'))/vendor/github.com/carbonengine/audio/develop + $(MonolithBranchRoot)\vendor\github.com\carbonengine\audio\develop .cmake-build-monolith + $(ProjectDir)$(MonolithBuildDir)\.vs-installtomonolith\$(Platform)\$(Configuration)\ $(ProjectDir.Replace('\', '/')) setlocal cd /d "$(ProjectDir)" @@ -39,18 +36,26 @@ set "PATH_TO_VCPKG_ROOT=$(ProjectDir)vendor\github.com\microsoft\vcpkg" echo Installing CarbonAudio to: $(MonolithInstallPrefix) cmake -S . -B $(MonolithBuildDir) -G "Visual Studio 18 2026" "-DCMAKE_TOOLCHAIN_FILE=$(RepoRoot)vendor/github.com/microsoft/vcpkg/scripts/buildsystems/vcpkg.cmake" "-DVCPKG_CHAINLOAD_TOOLCHAIN_FILE=$(RepoRoot)vendor/github.com/carbonengine/vcpkg-registry/toolchains/x64-windows-145-carbon.cmake" "-DVCPKG_OVERLAY_TRIPLETS=$(RepoRoot)vendor/github.com/carbonengine/vcpkg-registry/triplets" -DVCPKG_TARGET_TRIPLET=x64-windows-internal -DVCPKG_HOST_TRIPLET=x64-windows-internal -DVCPKG_USE_HOST_TOOLS=ON -DINSTALL_TO_MONOLITH=ON -DBUILD_TESTING=OFF "-DCMAKE_INSTALL_PREFIX=$(MonolithInstallPrefix)" if errorlevel 1 exit /b 1 +if exist "$(MonolithInstallDirWin)" echo Removing existing install at: $(MonolithInstallDirWin) +if exist "$(MonolithInstallDirWin)" rmdir /s /q "$(MonolithInstallDirWin)" cmake --build $(MonolithBuildDir) --config Internal --target INSTALL if errorlevel 1 exit /b 1 echo === CarbonAudio installed to $(MonolithInstallPrefix) === + $(VsScratchDir) + $(VsScratchDir) $(MonolithBuildScript) cd /d "$(ProjectDir)" if exist $(MonolithBuildDir) rmdir /s /q $(MonolithBuildDir) +mkdir "$(VsScratchDir)" 2>nul $(MonolithBuildScript) cd /d "$(ProjectDir)" -if exist $(MonolithBuildDir) rmdir /s /q $(MonolithBuildDir) +if exist $(MonolithBuildDir) rmdir /s /q $(MonolithBuildDir) +mkdir "$(VsScratchDir)" 2>nul +if exist "$(MonolithInstallDirWin)" echo Removing install at: $(MonolithInstallDirWin) +if exist "$(MonolithInstallDirWin)" rmdir /s /q "$(MonolithInstallDirWin)" $(ProjectDir)$(MonolithBuildDir)\Internal\_audio2_internal.pyd WIN32;NDEBUG;$(NMakePreprocessorDefinitions) diff --git a/src/AudManager.cpp b/src/AudManager.cpp index 0d87ee3..5eecdef 100644 --- a/src/AudManager.cpp +++ b/src/AudManager.cpp @@ -80,6 +80,7 @@ AudManager::AudManager( IRoot* lockobj ) : // Initialize sound prioritization system m_soundPrioritization = new SoundPrioritization(); m_spatialAudioSettings = new SpatialAudioSettings(); + m_obstructionOcclusion = std::make_unique( this ); } AudManager::~AudManager() @@ -117,6 +118,8 @@ void AudManager::Process() m_soundPrioritization->CullAudio(); } + m_obstructionOcclusion->Update(); + // Process bank requests, events, positions, RTPC, etc. AK::SoundEngine::RenderAudio(); @@ -340,6 +343,11 @@ void AudManager::UnregisterGameObject( AkGameObjectID gameObjID ) { m_soundPrioritization->UnregisterGameObject( gameObjID ); } + + if( m_obstructionOcclusion ) + { + m_obstructionOcclusion->RemoveEmitter( gameObjID ); + } } bool AudManager::InitCommunication() @@ -530,6 +538,41 @@ const bool AudManager::SpatialAudioIsSupported() return s_systemSupportsSpatialAudio; } +bool AudManager::SetEmitterLineOfSightBlockage( AkGameObjectID emitterID, float blockage ) +{ + return m_obstructionOcclusion->SetEmitterLineOfSightBlockage( emitterID, blockage ); +} + +float AudManager::GetEmitterOcclusion( AkGameObjectID emitterID ) const +{ + return m_obstructionOcclusion->GetEmitterOcclusion( emitterID ); +} + +void AudManager::ClearObstructionOcclusion() +{ + m_obstructionOcclusion->ClearAll(); +} + +bool AudManager::GetObstructionOcclusionEnabled() const +{ + return m_obstructionOcclusion->IsEnabled(); +} + +void AudManager::SetObstructionOcclusionEnabled( bool value ) +{ + m_obstructionOcclusion->SetEnabled( value ); +} + +float AudManager::GetObstructionOcclusionFadeRate() const +{ + return m_obstructionOcclusion->GetFadeRate(); +} + +void AudManager::SetObstructionOcclusionFadeRate( float value ) +{ + m_obstructionOcclusion->SetFadeRate( value ); +} + void AudManager::UpdateSettings( AudSettings* settings ) { m_settings = settings; @@ -776,6 +819,7 @@ void AudManager::Disable() } ClearBanks(); + m_obstructionOcclusion->Reset(); AudGeometry::ClearAllGeometry(); #ifndef AK_OPTIMIZED AK::SoundEngine::UnregisterResourceMonitorCallback(ResourceMonitorCallback); diff --git a/src/AudManager.h b/src/AudManager.h index e8594a3..027a123 100644 --- a/src/AudManager.h +++ b/src/AudManager.h @@ -9,6 +9,7 @@ #include "AudListener.h" #include "SoundPrioritization.h" #include "SpatialAudioSettings.h" +#include "AudObstructionOcclusion.h" #include "LowLevelIO/LowLevelIOHook.h" #include #include @@ -122,6 +123,18 @@ BLUE_CLASS( AudManager ) : bool GetSpatialAudioGeometryEnabled() const; // Enables or disables spatial audio geometry. void SetSpatialAudioGeometryEnabled( bool enabled ); + // Set a single line-of-sight blockage ratio for an emitter [0.0, 1.0]. 0 = clear line of sight. + bool SetEmitterLineOfSightBlockage( AkGameObjectID emitterID, float blockage ); + // Current, mid-fade occlusion value for an emitter. 0.0 if the emitter is clear or not tracked. + float GetEmitterOcclusion( AkGameObjectID emitterID ) const; + // Fade all obstruction/occlusion values back to clear. + void ClearObstructionOcclusion(); + // Enable or disable game-driven obstruction/occlusion processing. + bool GetObstructionOcclusionEnabled() const; + void SetObstructionOcclusionEnabled( bool value ); + // How fast obstruction/occlusion values fade towards their targets, in units per second. + float GetObstructionOcclusionFadeRate() const; + void SetObstructionOcclusionFadeRate( float value ); // Can be called to see if the current platform supports spatial audio. const bool SpatialAudioIsSupported(); // Stop all currently playing sounds on all game objects. @@ -239,6 +252,7 @@ BLUE_CLASS( AudManager ) : SoundPrioritization* m_soundPrioritization; SpatialAudioSettings* m_spatialAudioSettings; + std::unique_ptr m_obstructionOcclusion; // Map of game objects, used to guard Wwise callbacks std::unordered_map m_callbackGameObjects; diff --git a/src/AudManager_Blue.cpp b/src/AudManager_Blue.cpp index a4d8c02..e032abc 100644 --- a/src/AudManager_Blue.cpp +++ b/src/AudManager_Blue.cpp @@ -49,6 +49,10 @@ const Be::ClassInfo* AudManager::ExposeToBlue() MAP_PROPERTY( "transmissionLoss", GetTransmissionLoss, SetTransmissionLoss, "Per-mesh setting: transmission loss [0.0-1.0] applied to geometry surfaces when meshes are registered.") MAP_PROPERTY( "enableDiffraction", GetEnableDiffraction, SetEnableDiffraction, "Per-mesh setting: enable or disable geometric diffraction on mesh geometry.") MAP_PROPERTY( "enableDiffractionOnBoundaryEdges", GetEnableDiffractionOnBoundaryEdges, SetEnableDiffractionOnBoundaryEdges, "Per-mesh setting: switch to enable or disable geometric diffraction on boundary edges for this mesh.") + + // Obstruction / occlusion + MAP_PROPERTY( "obstructionOcclusionEnabled", GetObstructionOcclusionEnabled, SetObstructionOcclusionEnabled, "Enable or disable game-driven obstruction/occlusion processing. Disabling fades all values back to clear.") + MAP_PROPERTY( "obstructionOcclusionFadeRate", GetObstructionOcclusionFadeRate, SetObstructionOcclusionFadeRate, "How fast obstruction/occlusion values fade towards their targets, in units per second. 0 = instantaneous.") MAP_METHOD_AND_WRAP ( @@ -160,6 +164,26 @@ const Be::ClassInfo* AudManager::ExposeToBlue() ":param stateName: The state you want to set in Wwise." ) MAP_METHOD_AND_WRAP + ( + "SetEmitterLineOfSightBlockage", + SetEmitterLineOfSightBlockage, + "Set a line-of-sight blockage ratio [0.0-1.0] for an emitter (0 = clear). Returns True if the emitter exists." + ) + MAP_METHOD_AND_WRAP + ( + "GetEmitterOcclusion", + GetEmitterOcclusion, + "Get the occlusion value currently applied to an emitter. This is the live, mid-fade value " + "rather than the target that was set, so it can be used to observe a fade in progress. " + "Returns 0.0 if the emitter is clear or is not being tracked." + ) + MAP_METHOD_AND_WRAP + ( + "ClearObstructionOcclusion", + ClearObstructionOcclusion, + "Fade the obstruction/occlusion values of all tracked emitters back to clear." + ) + MAP_METHOD_AND_WRAP ( "SpatialAudioIsSupported", SpatialAudioIsSupported, diff --git a/src/AudObstructionOcclusion.cpp b/src/AudObstructionOcclusion.cpp new file mode 100644 index 0000000..3a44e29 --- /dev/null +++ b/src/AudObstructionOcclusion.cpp @@ -0,0 +1,243 @@ +//////////////////////////////////////////////////////////// +// +// Creator: Phevos Rinis +// Creation Date: Jul 2026 +// Copyright (c) 2026 Fernis Creations +// + +#include "AudObstructionOcclusion.h" +#include "AudManager.h" + +#include + +AudObstructionOcclusion::AudObstructionOcclusion(AudManager* audioManager) : + m_audioManager(audioManager), + m_fadeRate(DEFAULT_FADE_RATE), + m_hasUpdated(false), + m_enabled(true), + m_mutex("AudObstructionOcclusion", "m_mutex") +{} + +AudObstructionOcclusion::~AudObstructionOcclusion() +{} + +void AudObstructionOcclusion::Update() +{ + + if (m_audioManager == nullptr || m_audioManager->GetState() != AudioState::Enabled) + { + return; + } + + const auto now = std::chrono::steady_clock::now(); + float deltaSeconds = 0.0f; + + if (m_hasUpdated) + { + deltaSeconds = std::chrono::duration(now - m_lastUpdateTime).count(); + } + m_lastUpdateTime = now; + m_hasUpdated = true; + + CcpAutoMutex lock( m_mutex ); + + for (auto it = m_emitters.begin(); it != m_emitters.end();) + { + const AkGameObjectID emitterID = it->first; + EmitterState& state = it->second; + + bool culled = false; + const bool exists = m_audioManager->WithCallbackGameObject(emitterID, [&culled](AudGameObjResource* emitter) { + culled = emitter->IsCulled(); + }); + + if (!exists) + { + it = m_emitters.erase(it); + continue; + } + + const bool obstructionChanged = state.obstruction.Advance(deltaSeconds, m_fadeRate); + const bool occlusionChanged = state.occlusion.Advance(deltaSeconds, m_fadeRate); + + if (culled) + { + // The emitter is outside Wwise's view. Keep fading so time stays + // consistent, and make sure the values are resent once the emitter wakes up. + state.needsSend = true; + ++it; + continue; + } + + if (obstructionChanged || occlusionChanged || state.needsSend) + { + state.needsSend = !SendToWwise(emitterID, state); + } + + + ++it; + } +} + +bool AudObstructionOcclusion::SetObstructionOcclusion(AkGameObjectID emitterID, float obstruction, float occlusion) +{ + if (!m_enabled) + { + return false; + } + + if (m_audioManager == nullptr || m_audioManager->GetState() != AudioState::Enabled) + { + return false; + } + + // Values are relative to the listener, so setting them on the listener itself is meaningless. + if (emitterID == LISTENER_GAME_OBJ_ID) + { + return false; + } + + // We need to ask AudioManager about emitters that actually exist. + if (!m_audioManager->WithCallbackGameObject(emitterID, [](AudGameObjResource*) {})) + { + return false; + } + + CcpAutoMutex lock(m_mutex); + + const auto [entry, isNewEmitter] = m_emitters.try_emplace(emitterID); + EmitterState& state = entry->second; + + state.obstruction.SetTarget(obstruction); + state.occlusion.SetTarget(occlusion); + + if (isNewEmitter) + { + state.obstruction.SnapToTarget(); + state.occlusion.SnapToTarget(); + } + + return true; +} + +bool AudObstructionOcclusion::SetEmitterLineOfSightBlockage(AkGameObjectID emitterID, float blockage) +{ + // When Acoustics is On its transmission already attenuates, so skip occlusion to avoid stacking. + // Might change in the future with the addition of volumes. + const bool acousticsEnabled = m_audioManager != nullptr && m_audioManager->GetSpatialAudioGeometryEnabled(); + + float occlusion = 0.0f; + if (!acousticsEnabled) + { + occlusion = blockage; + } + + return SetObstructionOcclusion(emitterID, 0.0f, occlusion); +} + +float AudObstructionOcclusion::GetEmitterOcclusion(AkGameObjectID emitterID) const +{ + CcpAutoMutex lock(m_mutex); + + auto it = m_emitters.find(emitterID); + if (it == m_emitters.end()) + { + return 0.0f; + } + + return it->second.occlusion.currentValue; +} + +bool AudObstructionOcclusion::SendToWwise(AkGameObjectID emitterID, const EmitterState& state) const +{ + const AKRESULT result = AK::SoundEngine::SetObjectObstructionAndOcclusion( + emitterID, + LISTENER_GAME_OBJ_ID, + state.obstruction.currentValue, + state.occlusion.currentValue); + return result == AK_Success; +} + +void AudObstructionOcclusion::RemoveEmitter(AkGameObjectID emitterID) +{ + CcpAutoMutex lock(m_mutex); + m_emitters.erase(emitterID); +} + +void AudObstructionOcclusion::Reset() +{ + CcpAutoMutex lock(m_mutex); + m_emitters.clear(); + m_hasUpdated = false; +} + +void AudObstructionOcclusion::ClearAll() +{ + CcpAutoMutex lock(m_mutex); + for (auto& pair : m_emitters) + { + pair.second.obstruction.SetTarget(0.0f); + pair.second.occlusion.SetTarget(0.0f); + } +} + +bool AudObstructionOcclusion::IsEnabled() const +{ + return m_enabled; +} + +void AudObstructionOcclusion::SetEnabled(bool value) +{ + if (m_enabled == value) + { + return; + } + + m_enabled = value; + if (!m_enabled) + { + ClearAll(); + } +} + +float AudObstructionOcclusion::GetFadeRate() const +{ + return m_fadeRate; +} + +void AudObstructionOcclusion::SetFadeRate(float value) +{ + m_fadeRate = std::max(value, 0.0f); +} + +void AudObstructionOcclusion::FadingValue::SetTarget(float target) +{ + targetValue = std::clamp(target, 0.0f, 1.0f); +} + +bool AudObstructionOcclusion::FadingValue::Advance(float deltaSeconds, float fadeRate) +{ + if (currentValue == targetValue) + { + return false; + } + + if (fadeRate <= 0.0f) + { + currentValue = targetValue; + return true; + } + + const float oldValue = currentValue; + const float step = fadeRate * deltaSeconds; + + if (oldValue > targetValue) + { + currentValue = std::clamp(oldValue - step, targetValue, oldValue); + } + else + { + currentValue = std::clamp(oldValue + step, oldValue, targetValue); + } + return currentValue != oldValue; +} diff --git a/src/AudObstructionOcclusion.h b/src/AudObstructionOcclusion.h new file mode 100644 index 0000000..cde8039 --- /dev/null +++ b/src/AudObstructionOcclusion.h @@ -0,0 +1,124 @@ +//////////////////////////////////////////////////////////// +// +// Creator: Phevos Rinis +// Creation Date: Jul 2026 +// Copyright (c) 2026 Fernis Creations +// + +#pragma once + +#include +#include + +#include + +#include + +class AudManager; + +/** + * @brief Owns the obstruction and occlusion of every emitter and feeds those values to Wwise. + * + * The game tells us how much of an emitter's line of sight to the listener is blocked, and this + * class turns that into the obstruction and occlusion Wwise applies + * (see @c AK::SoundEngine::SetObjectObstructionAndOcclusion). Each + * value fades towards its target so that sounds do not pop when something moves in front of them. + * + */ +class AudObstructionOcclusion +{ +public: + + AudObstructionOcclusion(AudManager* audioManager); + ~AudObstructionOcclusion(); + + /** + * @brief Advances every fade and sends the values that changed to Wwise. + */ + void Update(); + + /** + * @brief Sets the obstruction and occlusion an emitter fades towards. + * + * @param emitterID The emitter to block. + * @param obstruction Target obstruction [0.0, 1.0] + * @param occlusion Target occlusion [0.0, 1.0] + * + * @return True if the emitter exists and the values were accepted. + */ + bool SetObstructionOcclusion(AkGameObjectID emitterID, float obstruction, float occlusion); + + /** + * @brief Sets how much of an emitter's line of sight to the listener is blocked. + * + * @param emitterID The emitter to block. + * @param blockage How blocked the line of sight is [0.0, 1.0]. 0 is a clear line of sight. + * + * @return True if the emitter exists and the value was accepted. + */ + bool SetEmitterLineOfSightBlockage(AkGameObjectID emitterID, float blockage); + + /** + * @brief The occlusion currently applied to an emitter. + */ + float GetEmitterOcclusion(AkGameObjectID emitterID) const; + + /// Drops an emitter straight away without fading it out, for when the game object goes away. + void RemoveEmitter( AkGameObjectID emitterID ); + + /// Forgets every emitter and the fade clock, for when audio is disabled. + void Reset(); + + /// Fades every tracked emitter back to clear. + void ClearAll(); + + /** + * @brief Controls whether game driven obstruction and occlusion is processed at all. + */ + bool IsEnabled() const; + void SetEnabled(bool value); + + /** + * @brief How fast values move towards their target, in units per second. + */ + float GetFadeRate() const; + void SetFadeRate(float value); + +private: + + /// A single value on its way to a target, and the maths that moves it there. + struct FadingValue + { + float currentValue = 0.0f; + float targetValue = 0.0f; + + void SetTarget(float target); + bool Advance(float deltaSeconds, float fadeRate); + bool ReachedTarget() const { return currentValue == targetValue; }; + void SnapToTarget() { currentValue = targetValue; }; + }; + + /// Everything we keep for one blocked emitter. + struct EmitterState + { + FadingValue obstruction; + FadingValue occlusion; + + bool needsSend = true; + + }; + + bool SendToWwise(AkGameObjectID emitterID, const EmitterState& state) const; + + static constexpr float DEFAULT_FADE_RATE = 1.0f; + + AudManager* m_audioManager; + std::unordered_map m_emitters; + float m_fadeRate; + bool m_hasUpdated; + bool m_enabled; + mutable CcpMutex m_mutex; + + std::chrono::steady_clock::time_point m_lastUpdateTime; + +}; \ No newline at end of file diff --git a/tests/python/audiotests/test/test_audgameobj_enabled_exposure.py b/tests/python/audiotests/test/test_audgameobj_enabled_exposure.py index 0d6c9e1..04611b6 100644 --- a/tests/python/audiotests/test/test_audgameobj_enabled_exposure.py +++ b/tests/python/audiotests/test/test_audgameobj_enabled_exposure.py @@ -8,7 +8,7 @@ NONESSENTIAL_BNK, NONESSENTIAL_BNK_EVENT, NONESSENTIAL_STREAM_BNK, NONESSENTIAL_STREAM_EVENT ) from audiotests.base_test_class import BaseAudio2TestClass -from audiotests.utils import PumpOSWithTimeout +from audiotests.utils import WaitForEmitterToWake, WaitForSoundBanksToLoad @@ -31,8 +31,14 @@ def setUp(self): self.listener = audio2.GetListener() self.listener.SetPosition((0,0,0), (0,0,0), (0,0,0)) - PumpOSWithTimeout(self.alwaysTrueBoolean, maxTries=3) - + self.assertTrue( + WaitForSoundBanksToLoad([ + LOOP_EVENT, ONE_SHOT_EVENT, ESSENTIAL_EVENT, NONESSENTIAL_BNK_EVENT, NONESSENTIAL_STREAM_EVENT + ]), + "Timed out waiting for the test SoundBanks to load." + ) + self.assertTrue(WaitForEmitterToWake(self.emitter), "Timed out waiting for the emitter to be woken up.") + def tearDown(self): self.emitter.eventPrefix = "" self.emitter.StopAll() @@ -266,14 +272,7 @@ def test_audgameobjresource_wakes_if_muted(self): def test_audgameobjresource_sanitizes_events(self): def assert_event_sanitized(event_name): - playingID = self.emitter.SendEvent(event_name) - tries = 0 - while playingID <= 0 and tries < 5: - blue.pyos.synchro.SleepWallclock(100) - blue.os.Pump() - playingID = self.emitter.SendEvent(event_name) - tries += 1 - self.assertTrue(playingID > 0) + self.assertTrue(self.emitter.SendEvent(event_name) > 0) assert_event_sanitized(" {}".format(ONE_SHOT_EVENT)) assert_event_sanitized("{} ".format(ONE_SHOT_EVENT)) diff --git a/tests/python/audiotests/test/test_audmanager_exposure.py b/tests/python/audiotests/test/test_audmanager_exposure.py index 198482e..104ba94 100644 --- a/tests/python/audiotests/test/test_audmanager_exposure.py +++ b/tests/python/audiotests/test/test_audmanager_exposure.py @@ -8,7 +8,7 @@ from audiotests.base_test_class import COMMON_BNK, LOOP_BNK, LOOP_EVENT, ONE_SHOT_BNK from audiotests.base_test_class import GetAudioMetadataFromFile -from audiotests.utils import PumpOSWithTimeout +from audiotests.utils import PumpOSWithTimeout, WaitForSoundBanksToLoad class TestAudManagerExposure(unittest.TestCase): @classmethod @@ -143,11 +143,11 @@ def test_audmanager_stopall(self): import audio2 self.audioManager.LoadBank(COMMON_BNK) self.audioManager.LoadBank(LOOP_BNK) - PumpOSWithTimeout(self.alwaysTrueBoolean, maxTries=3) + self.assertTrue(WaitForSoundBanksToLoad([LOOP_EVENT]), "Timed out waiting for the test SoundBanks to load.") emitter1 = audio2.AudEmitter("emitter1") emitter2 = audio2.AudEmitter("emitter2") - self.assertTrue(emitter1.SendEvent(LOOP_EVENT) > 0) + self.assertTrue(emitter1.SendEvent(LOOP_EVENT) > 0) self.assertTrue(emitter2.SendEvent(LOOP_EVENT) > 0) self.audioManager.StopAll() diff --git a/tests/python/audiotests/test/test_auduiplayer_exposure.py b/tests/python/audiotests/test/test_auduiplayer_exposure.py index 40688a9..8a3cce4 100644 --- a/tests/python/audiotests/test/test_auduiplayer_exposure.py +++ b/tests/python/audiotests/test/test_auduiplayer_exposure.py @@ -4,7 +4,7 @@ from audiotests.base_test_class import COMMON_BNK, LOOP_BNK, LOOP_EVENT, ONE_SHOT_BNK, ONE_SHOT_EVENT from audiotests.base_test_class import BaseAudio2TestClass -from audiotests.utils import PumpOSWithTimeout +from audiotests.utils import PumpOSWithTimeout, WaitForEmitterToWake, WaitForSoundBanksToLoad class TestAudUIPlayerExposure(BaseAudio2TestClass): @@ -14,8 +14,15 @@ def setUpClass(cls): cls.Initialize(cls, defaultSoundBanks=[COMMON_BNK, LOOP_BNK, ONE_SHOT_BNK]) def setUp(self): + import audio2 self.audioManager.Enable() - PumpOSWithTimeout(self.alwaysTrueBoolean, maxTries=3) + uiPlayer = audio2.GetUIPlayer() + uiPlayer.SetPlacement((0,0,0), (0,0,0), (0,0,0)) + self.assertTrue( + WaitForSoundBanksToLoad([LOOP_EVENT, ONE_SHOT_EVENT]), + "Timed out waiting for the test SoundBanks to load." + ) + self.assertTrue(WaitForEmitterToWake(uiPlayer), "Timed out waiting for the UI player to be woken up.") def tearDown(self): self.audioManager.Disable() diff --git a/tests/python/audiotests/test/test_obstruction_occlusion_exposure.py b/tests/python/audiotests/test/test_obstruction_occlusion_exposure.py new file mode 100644 index 0000000..5adfe5f --- /dev/null +++ b/tests/python/audiotests/test/test_obstruction_occlusion_exposure.py @@ -0,0 +1,208 @@ +# Copyright © 2026 CCP ehf. + +from audiotests.base_test_class import COMMON_BNK +from audiotests.base_test_class import BaseAudio2TestClass +from audiotests.utils import PumpOSWithTimeout + + +INSTANT_FADE_RATE = 0.0 +SLOW_FADE_RATE = 0.01 +FAST_FADE_RATE = 100.0 + + +class TestObstructionOcclusionExposure(BaseAudio2TestClass): + """Tests line-of-sight occlusion, in particular the interpolation that produces the fade.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Initialize(cls, defaultSoundBanks=[COMMON_BNK]) + + def setUp(self): + import audio2 + self.audioManager.Enable() + self.manager = self.audioManager.manager + + self.emitter = audio2.AudEmitter("occlusionTestEmitter") + self.emitter.SetPlacement((0, 0, 0), (0, 0, 0), (0, 0, 0)) + + self.manager.obstructionOcclusionEnabled = True + self.manager.obstructionOcclusionFadeRate = INSTANT_FADE_RATE + + def tearDown(self): + self.emitter = None + self.audioManager.Disable() + + def Pump(self, times=3): + """Tick the engine. Each tick runs AudManager::Process(), which advances the occlusion fades. + """ + PumpOSWithTimeout(self.alwaysTrueBoolean, maxTries=times) + + def GetOcclusion(self): + return self.manager.GetEmitterOcclusion(self.emitter.ID) + + def SetBlockage(self, blockage): + return self.manager.SetEmitterLineOfSightBlockage(self.emitter.ID, blockage) + + def EstablishClear(self): + self.assertTrue(self.SetBlockage(0.0)) + self.Pump() + + def test_a_first_value_is_applied_at_once(self): + self.manager.obstructionOcclusionFadeRate = SLOW_FADE_RATE + + self.assertTrue(self.SetBlockage(1.0)) + self.Pump() + + self.assertEqual(self.GetOcclusion(), 1.0) + + def test_an_emitter_that_is_back_to_clear_still_fades(self): + + self.manager.obstructionOcclusionFadeRate = INSTANT_FADE_RATE + self.assertTrue(self.SetBlockage(1.0)) + self.Pump() + self.assertTrue(self.SetBlockage(0.0)) + self.Pump() + self.assertEqual(self.GetOcclusion(), 0.0) + + self.manager.obstructionOcclusionFadeRate = SLOW_FADE_RATE + self.assertTrue(self.SetBlockage(1.0)) + self.Pump() + + occlusion = self.GetOcclusion() + self.assertGreater(occlusion, 0.0, "Occlusion never started fading back in.") + self.assertLess(occlusion, 1.0, "Occlusion jumped to its target instead of fading.") + + def test_occlusion_is_applied_immediately_at_a_zero_fade_rate(self): + self.EstablishClear() + self.manager.obstructionOcclusionFadeRate = INSTANT_FADE_RATE + + self.assertTrue(self.SetBlockage(1.0)) + self.Pump() + + self.assertEqual(self.GetOcclusion(), 1.0) + + def test_occlusion_fades_in_gradually(self): + """The value has to interpolate towards its target rather than jumping straight to it.""" + self.EstablishClear() + self.manager.obstructionOcclusionFadeRate = SLOW_FADE_RATE + + self.assertTrue(self.SetBlockage(1.0)) + self.Pump() + firstValue = self.GetOcclusion() + self.Pump() + secondValue = self.GetOcclusion() + + self.assertGreater(firstValue, 0.0, "Occlusion never started fading in.") + self.assertGreater(secondValue, firstValue, "Occlusion stopped advancing towards its target.") + self.assertLess(secondValue, 1.0, "Occlusion jumped to its target instead of fading.") + + def test_occlusion_fades_back_out_gradually(self): + self.manager.obstructionOcclusionFadeRate = INSTANT_FADE_RATE + self.assertTrue(self.SetBlockage(1.0)) + self.Pump() + self.assertEqual(self.GetOcclusion(), 1.0) + + self.manager.obstructionOcclusionFadeRate = SLOW_FADE_RATE + self.assertTrue(self.SetBlockage(0.0)) + self.Pump() + firstValue = self.GetOcclusion() + self.Pump() + secondValue = self.GetOcclusion() + + self.assertLess(firstValue, 1.0, "Occlusion never started fading out.") + self.assertLess(secondValue, firstValue, "Occlusion stopped advancing towards its target.") + self.assertGreater(secondValue, 0.0, "Occlusion jumped to its target instead of fading.") + + def test_a_fade_settles_on_its_target_without_overshooting(self): + """A single tick large enough to cover the whole fade must stop exactly on the target.""" + self.EstablishClear() + self.manager.obstructionOcclusionFadeRate = FAST_FADE_RATE + + self.assertTrue(self.SetBlockage(1.0)) + self.Pump() + + self.assertEqual(self.GetOcclusion(), 1.0) + + def test_a_fade_out_settles_on_its_target_without_undershooting(self): + self.manager.obstructionOcclusionFadeRate = INSTANT_FADE_RATE + self.assertTrue(self.SetBlockage(1.0)) + self.Pump() + + self.manager.obstructionOcclusionFadeRate = FAST_FADE_RATE + self.assertTrue(self.SetBlockage(0.0)) + self.Pump() + + self.assertEqual(self.GetOcclusion(), 0.0) + + def test_blockage_is_clamped_to_a_valid_range(self): + self.manager.obstructionOcclusionFadeRate = INSTANT_FADE_RATE + + self.assertTrue(self.SetBlockage(5.0)) + self.Pump() + self.assertEqual(self.GetOcclusion(), 1.0) + + self.assertTrue(self.SetBlockage(-1.0)) + self.Pump() + self.assertEqual(self.GetOcclusion(), 0.0) + + def test_a_negative_fade_rate_is_clamped_to_zero(self): + self.manager.obstructionOcclusionFadeRate = -5.0 + + self.assertEqual(self.manager.obstructionOcclusionFadeRate, 0.0) + + def test_clearing_returns_every_emitter_to_clear(self): + self.manager.obstructionOcclusionFadeRate = INSTANT_FADE_RATE + self.assertTrue(self.SetBlockage(1.0)) + self.Pump() + self.assertEqual(self.GetOcclusion(), 1.0) + + self.manager.ClearObstructionOcclusion() + self.Pump() + + self.assertEqual(self.GetOcclusion(), 0.0) + + def test_disabling_clears_existing_values_and_rejects_new_ones(self): + self.manager.obstructionOcclusionFadeRate = INSTANT_FADE_RATE + self.assertTrue(self.SetBlockage(1.0)) + self.Pump() + self.assertEqual(self.GetOcclusion(), 1.0) + + self.manager.obstructionOcclusionEnabled = False + self.Pump() + self.assertEqual(self.GetOcclusion(), 0.0) + + self.assertFalse(self.SetBlockage(1.0)) + self.Pump() + self.assertEqual(self.GetOcclusion(), 0.0) + + def test_blockage_is_rejected_for_an_emitter_that_does_not_exist(self): + + unknownEmitterID = self.emitter.ID + 1000000 + + self.assertFalse(self.manager.SetEmitterLineOfSightBlockage(unknownEmitterID, 1.0)) + self.assertEqual(self.manager.GetEmitterOcclusion(unknownEmitterID), 0.0) + + def test_blockage_is_rejected_for_the_listener(self): + """Occlusion is measured relative to the listener, so occluding the listener is meaningless.""" + import audio2 + listenerID = audio2.GetListener().ID + + self.assertFalse(self.manager.SetEmitterLineOfSightBlockage(listenerID, 1.0)) + self.assertEqual(self.manager.GetEmitterOcclusion(listenerID), 0.0) + + def test_occlusion_is_suppressed_while_acoustics_are_enabled(self): + """Acoustics transmission already attenuates, so occlusion must not stack on top of it.""" + self.manager.spatialAudioGeometryEnabled = True + if not self.manager.spatialAudioGeometryEnabled: + self.skipTest("Spatial audio geometry is not available on this platform.") + + try: + self.manager.obstructionOcclusionFadeRate = INSTANT_FADE_RATE + + self.assertTrue(self.SetBlockage(1.0)) + self.Pump() + + self.assertEqual(self.GetOcclusion(), 0.0) + finally: + self.manager.spatialAudioGeometryEnabled = False diff --git a/tests/python/audiotests/test/test_python_audiomanager.py b/tests/python/audiotests/test/test_python_audiomanager.py index 48b197a..5a332e1 100644 --- a/tests/python/audiotests/test/test_python_audiomanager.py +++ b/tests/python/audiotests/test/test_python_audiomanager.py @@ -4,7 +4,7 @@ from audiotests.base_test_class import COMMON_BNK, LOOP_BNK, LOOP_EVENT, ONE_SHOT_BNK, ONE_SHOT_EVENT, SOUNDBANK_FILEPATH from audiotests.base_test_class import BaseAudio2TestClass -from audiotests.utils import GetAudioMetadataFromFile, PumpOSWithTimeout +from audiotests.utils import GetAudioMetadataFromFile, PumpOSWithTimeout, WaitForEmitterToWake, WaitForSoundBanksToLoad class TestPythonAudioManager(BaseAudio2TestClass): @classmethod @@ -68,7 +68,8 @@ def test_disable_then_enable_can_load_bank_and_play_event(self): emitter.SetPlacement((0,0,0), (0,0,0), (0,0,0)) listener = audio2.GetListener() listener.SetPosition((0,0,0), (0,0,0), (0,0,0)) - PumpOSWithTimeout(self.alwaysTrueBoolean, maxTries=3) + self.assertTrue(WaitForSoundBanksToLoad([LOOP_EVENT]), "Timed out waiting for the test SoundBanks to load.") + self.assertTrue(WaitForEmitterToWake(emitter), "Timed out waiting for the emitter to be woken up.") playingID = emitter.SendEvent(LOOP_EVENT) self.assertTrue(playingID > 0) emitter.StopAll() @@ -83,7 +84,8 @@ def test_stopped_loop_does_not_resume_after_disable_enable(self): emitter.SetPlacement((0,0,0), (0,0,0), (0,0,0)) listener = audio2.GetListener() listener.SetPosition((0,0,0), (0,0,0), (0,0,0)) - PumpOSWithTimeout(self.alwaysTrueBoolean, maxTries=3) + self.assertTrue(WaitForSoundBanksToLoad([LOOP_EVENT]), "Timed out waiting for the test SoundBanks to load.") + self.assertTrue(WaitForEmitterToWake(emitter), "Timed out waiting for the emitter to be woken up.") playingID = emitter.SendEvent(LOOP_EVENT) self.assertTrue(playingID > 0) @@ -110,7 +112,11 @@ def test_loop_stopped_by_posted_event_does_not_resume_after_disable_enable(self) emitter.SetPlacement((0,0,0), (0,0,0), (0,0,0)) listener = audio2.GetListener() listener.SetPosition((0,0,0), (0,0,0), (0,0,0)) - PumpOSWithTimeout(self.alwaysTrueBoolean, maxTries=3) + self.assertTrue( + WaitForSoundBanksToLoad([LOOP_EVENT, ONE_SHOT_EVENT]), + "Timed out waiting for the test SoundBanks to load." + ) + self.assertTrue(WaitForEmitterToWake(emitter), "Timed out waiting for the emitter to be woken up.") loopPlayingID = emitter.SendEvent(LOOP_EVENT) self.assertTrue(loopPlayingID > 0) diff --git a/tests/python/audiotests/utils.py b/tests/python/audiotests/utils.py index 87296b0..ebf3f38 100644 --- a/tests/python/audiotests/utils.py +++ b/tests/python/audiotests/utils.py @@ -15,6 +15,24 @@ def PumpOSWithTimeout(booleanFunc, maxTries=10): numTries += 1 return not booleanFunc() +def WaitForSoundBanksToLoad(events, maxTries=20): + """Pump until every given event can be posted, which means its SoundBank has finished loading. + SoundBanks are loaded asynchronously so SendEvent returns 0 until the load callback has been hit. + """ + import audio2 + probe = audio2.AudEmitter("soundBankLoadProbe") + probe.SetPlacement((0,0,0), (0,0,0), (0,0,0)) + allLoaded = True + for event in events: + if not PumpOSWithTimeout(lambda event=event: probe.SendEvent(event) <= 0, maxTries=maxTries): + allLoaded = False + probe.StopAll() + return allLoaded + +def WaitForEmitterToWake(emitter, maxTries=10): + """Pump until sound prioritization has woken the emitter. Culled emitters queue events instead of posting them.""" + return PumpOSWithTimeout(emitter.IsCulled, maxTries=maxTries) + def GetAudioMetadataFromFile(): """Gets audio metadata from file and returns it as a dict. Also converts eventIDs to int in the process.""" with open(AUDIO_METADATA_FILEPATH, "r") as f: