2026-02-03 19:49:56 -08:00
|
|
|
#include "audio/activity_sound_manager.hpp"
|
2026-02-09 01:01:24 -08:00
|
|
|
#include "audio/audio_engine.hpp"
|
2026-02-03 19:49:56 -08:00
|
|
|
#include "pipeline/asset_manager.hpp"
|
|
|
|
|
#include "core/logger.hpp"
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
#include <cctype>
|
|
|
|
|
|
|
|
|
|
namespace wowee {
|
|
|
|
|
namespace audio {
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
std::vector<std::string> buildClassicSet(const std::string& material) {
|
|
|
|
|
std::vector<std::string> out;
|
|
|
|
|
for (char c = 'A'; c <= 'L'; ++c) {
|
|
|
|
|
out.push_back("Sound\\Character\\Footsteps\\mFootMediumLarge" + material + std::string(1, c) + ".wav");
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
ActivitySoundManager::ActivitySoundManager() : rng(std::random_device{}()) {}
|
|
|
|
|
ActivitySoundManager::~ActivitySoundManager() { shutdown(); }
|
|
|
|
|
|
|
|
|
|
bool ActivitySoundManager::initialize(pipeline::AssetManager* assets) {
|
|
|
|
|
shutdown();
|
|
|
|
|
assetManager = assets;
|
|
|
|
|
if (!assetManager) return false;
|
|
|
|
|
|
2026-02-23 06:22:30 -08:00
|
|
|
// Voice profile clips (jump, swim, hardLand, combat vocals) are set at
|
|
|
|
|
// character spawn via setCharacterVoiceProfile() with the correct race/gender.
|
2026-02-03 19:49:56 -08:00
|
|
|
|
|
|
|
|
preloadCandidates(splashEnterClips, {
|
2026-02-09 14:50:14 -08:00
|
|
|
"Sound\\Character\\Footsteps\\EnterWaterSplash\\EnterWaterSmallA.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\EnterWaterSplash\\EnterWaterMediumA.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\EnterWaterSplash\\EnterWaterGiantA.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsMediumWaterA.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsMediumWaterB.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsMediumWaterC.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsMediumWaterD.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsMediumWaterE.wav"
|
2026-02-03 19:49:56 -08:00
|
|
|
});
|
|
|
|
|
splashExitClips = splashEnterClips;
|
|
|
|
|
|
|
|
|
|
preloadLandingSet(FootstepSurface::STONE, "Stone");
|
|
|
|
|
preloadLandingSet(FootstepSurface::DIRT, "Dirt");
|
|
|
|
|
preloadLandingSet(FootstepSurface::GRASS, "Grass");
|
|
|
|
|
preloadLandingSet(FootstepSurface::WOOD, "Wood");
|
|
|
|
|
preloadLandingSet(FootstepSurface::METAL, "Metal");
|
|
|
|
|
preloadLandingSet(FootstepSurface::WATER, "Water");
|
|
|
|
|
preloadLandingSet(FootstepSurface::SNOW, "Snow");
|
|
|
|
|
|
2026-02-05 14:01:26 -08:00
|
|
|
preloadCandidates(meleeSwingClips, {
|
|
|
|
|
"Sound\\Item\\Weapons\\Sword\\SwordSwing1.wav",
|
|
|
|
|
"Sound\\Item\\Weapons\\Sword\\SwordSwing2.wav",
|
|
|
|
|
"Sound\\Item\\Weapons\\Sword\\SwordSwing3.wav",
|
|
|
|
|
"Sound\\Item\\Weapons\\Sword\\SwordHit1.wav",
|
|
|
|
|
"Sound\\Item\\Weapons\\Sword\\SwordHit2.wav",
|
|
|
|
|
"Sound\\Item\\Weapons\\Sword\\SwordHit3.wav",
|
|
|
|
|
"Sound\\Item\\Weapons\\OneHanded\\Sword\\SwordSwing1.wav",
|
|
|
|
|
"Sound\\Item\\Weapons\\OneHanded\\Sword\\SwordSwing2.wav",
|
|
|
|
|
"Sound\\Item\\Weapons\\OneHanded\\Sword\\SwordSwing3.wav",
|
|
|
|
|
"Sound\\Item\\Weapons\\Melee\\MeleeSwing1.wav",
|
|
|
|
|
"Sound\\Item\\Weapons\\Melee\\MeleeSwing2.wav",
|
|
|
|
|
"Sound\\Item\\Weapons\\Melee\\MeleeSwing3.wav"
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-03 19:49:56 -08:00
|
|
|
initialized = true;
|
|
|
|
|
core::Logger::getInstance().info("Activity SFX loaded: jump=", jumpClips.size(),
|
|
|
|
|
" splash=", splashEnterClips.size(),
|
|
|
|
|
" swimLoop=", swimLoopClips.size());
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::shutdown() {
|
|
|
|
|
stopSwimLoop();
|
|
|
|
|
stopOneShot();
|
|
|
|
|
std::remove(loopTempPath.c_str());
|
|
|
|
|
std::remove(oneShotTempPath.c_str());
|
|
|
|
|
for (auto& set : landingSets) set.clips.clear();
|
|
|
|
|
jumpClips.clear();
|
|
|
|
|
splashEnterClips.clear();
|
|
|
|
|
splashExitClips.clear();
|
|
|
|
|
swimLoopClips.clear();
|
|
|
|
|
hardLandClips.clear();
|
2026-02-05 14:01:26 -08:00
|
|
|
meleeSwingClips.clear();
|
2026-02-03 19:49:56 -08:00
|
|
|
swimmingActive = false;
|
|
|
|
|
swimMoving = false;
|
|
|
|
|
initialized = false;
|
|
|
|
|
assetManager = nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 14:50:14 -08:00
|
|
|
void ActivitySoundManager::update(float deltaTime) {
|
2026-02-03 19:49:56 -08:00
|
|
|
reapProcesses();
|
2026-02-09 14:50:14 -08:00
|
|
|
|
|
|
|
|
// Play swimming stroke sounds periodically when swimming and moving
|
|
|
|
|
if (swimmingActive && swimMoving && !swimLoopClips.empty()) {
|
|
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
|
|
|
float elapsed = std::chrono::duration<float>(now - lastSwimStrokeAt).count();
|
|
|
|
|
|
|
|
|
|
// Play swimming stroke sound every 0.8 seconds (swim stroke rhythm)
|
|
|
|
|
if (lastSwimStrokeAt.time_since_epoch().count() == 0 || elapsed >= 0.8f) {
|
|
|
|
|
std::uniform_int_distribution<size_t> clipDist(0, swimLoopClips.size() - 1);
|
|
|
|
|
const Sample& sample = swimLoopClips[clipDist(rng)];
|
|
|
|
|
|
|
|
|
|
// Play as one-shot 2D sound
|
|
|
|
|
float volume = 0.6f * volumeScale;
|
|
|
|
|
AudioEngine::instance().playSound2D(sample.data, volume, false);
|
|
|
|
|
|
|
|
|
|
lastSwimStrokeAt = now;
|
|
|
|
|
}
|
|
|
|
|
} else if (!swimmingActive) {
|
|
|
|
|
// Reset timer when not swimming
|
|
|
|
|
lastSwimStrokeAt = std::chrono::steady_clock::time_point{};
|
|
|
|
|
}
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::preloadCandidates(std::vector<Sample>& out, const std::vector<std::string>& candidates) {
|
|
|
|
|
if (!assetManager) return;
|
|
|
|
|
for (const auto& path : candidates) {
|
|
|
|
|
if (!assetManager->fileExists(path)) continue;
|
|
|
|
|
auto data = assetManager->readFile(path);
|
|
|
|
|
if (data.empty()) continue;
|
|
|
|
|
out.push_back({path, std::move(data)});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::preloadLandingSet(FootstepSurface surface, const std::string& material) {
|
|
|
|
|
auto& clips = landingSets[static_cast<size_t>(surface)].clips;
|
|
|
|
|
preloadCandidates(clips, buildClassicSet(material));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::rebuildJumpClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male) {
|
|
|
|
|
jumpClips.clear();
|
|
|
|
|
const std::string gender = male ? "Male" : "Female";
|
|
|
|
|
const std::string prefix = "Sound\\Character\\" + raceFolder + "\\";
|
|
|
|
|
const std::string stem = raceBase + gender;
|
2026-02-19 21:50:32 -08:00
|
|
|
|
|
|
|
|
// Determine PlayerExertions folder/stem (same logic as combat vocals)
|
|
|
|
|
std::string exertFolder = stem + "Final";
|
|
|
|
|
std::string exertStem = stem;
|
|
|
|
|
if (raceBase == "Orc" && male) exertFolder = "OrcMale";
|
|
|
|
|
if (raceBase == "Human" && !male) exertStem = "HumanFeamle"; // Blizzard typo
|
|
|
|
|
std::string exertRace = raceBase;
|
|
|
|
|
if (raceBase == "Scourge") { exertRace = "Undead"; exertFolder = "Undead" + gender + "Final"; exertStem = "Undead" + gender; }
|
|
|
|
|
const std::string exertPrefix = "Sound\\Character\\PlayerExertions\\" + exertFolder + "\\" + exertStem + "Main";
|
|
|
|
|
|
2026-02-03 19:49:56 -08:00
|
|
|
preloadCandidates(jumpClips, {
|
2026-02-19 21:50:32 -08:00
|
|
|
// PlayerExertions (verified from MPQ manifest)
|
|
|
|
|
exertPrefix + "Jump.wav",
|
|
|
|
|
// movement_sound_manager convention (also verified working)
|
|
|
|
|
prefix + stem + "Jump1.wav",
|
|
|
|
|
prefix + stem + "Land1.wav",
|
|
|
|
|
// Other common variants
|
2026-02-03 19:49:56 -08:00
|
|
|
prefix + stem + "JumpA.wav",
|
|
|
|
|
prefix + stem + "JumpB.wav",
|
|
|
|
|
prefix + stem + "Jump.wav",
|
2026-02-19 21:50:32 -08:00
|
|
|
prefix + gender + "\\" + stem + "JumpA.wav",
|
|
|
|
|
prefix + gender + "\\" + stem + "JumpB.wav",
|
|
|
|
|
prefix + stem + "\\" + stem + "Jump01.wav",
|
|
|
|
|
prefix + stem + "\\" + stem + "Jump02.wav",
|
2026-02-03 19:49:56 -08:00
|
|
|
});
|
2026-02-23 06:22:30 -08:00
|
|
|
if (jumpClips.empty()) {
|
|
|
|
|
LOG_WARNING("No jump clips found for ", stem, " (tried exert prefix: ", exertPrefix, ")");
|
|
|
|
|
} else {
|
|
|
|
|
LOG_INFO("Loaded ", jumpClips.size(), " jump clips for ", stem);
|
|
|
|
|
}
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::rebuildSwimLoopClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male) {
|
|
|
|
|
swimLoopClips.clear();
|
2026-02-09 14:50:14 -08:00
|
|
|
|
|
|
|
|
// WoW 3.3.5a doesn't have dedicated swim loop sounds
|
|
|
|
|
// Use water splash/footstep sounds as swimming stroke sounds
|
2026-02-03 19:49:56 -08:00
|
|
|
preloadCandidates(swimLoopClips, {
|
2026-02-09 14:50:14 -08:00
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsMediumWaterA.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsMediumWaterB.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsMediumWaterC.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsMediumWaterD.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsMediumWaterE.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsSmallWaterA.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsSmallWaterB.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsSmallWaterC.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsSmallWaterD.wav",
|
|
|
|
|
"Sound\\Character\\Footsteps\\WaterSplash\\FootStepsSmallWaterE.wav"
|
2026-02-03 19:49:56 -08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::rebuildHardLandClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male) {
|
|
|
|
|
hardLandClips.clear();
|
|
|
|
|
const std::string gender = male ? "Male" : "Female";
|
|
|
|
|
const std::string prefix = "Sound\\Character\\" + raceFolder + "\\";
|
|
|
|
|
const std::string stem = raceBase + gender;
|
|
|
|
|
preloadCandidates(hardLandClips, {
|
|
|
|
|
prefix + stem + "\\" + stem + "LandHard01.wav",
|
|
|
|
|
prefix + stem + "\\" + stem + "LandHard02.wav",
|
|
|
|
|
prefix + stem + "LandHard01.wav",
|
|
|
|
|
prefix + stem + "LandHard02.wav"
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool ActivitySoundManager::playOneShot(const std::vector<Sample>& clips, float volume, float pitchLo, float pitchHi) {
|
|
|
|
|
if (clips.empty()) return false;
|
2026-02-19 02:46:52 -08:00
|
|
|
if (volumeScale <= 0.0001f || volume <= 0.0001f) return true; // Intentionally muted
|
2026-02-03 19:49:56 -08:00
|
|
|
reapProcesses();
|
2026-02-03 22:24:17 -08:00
|
|
|
if (oneShotPid != INVALID_PROCESS) return false;
|
2026-02-03 19:49:56 -08:00
|
|
|
|
|
|
|
|
std::uniform_int_distribution<size_t> clipDist(0, clips.size() - 1);
|
|
|
|
|
const Sample& sample = clips[clipDist(rng)];
|
|
|
|
|
std::ofstream out(oneShotTempPath, std::ios::binary);
|
|
|
|
|
if (!out) return false;
|
|
|
|
|
out.write(reinterpret_cast<const char*>(sample.data.data()), static_cast<std::streamsize>(sample.data.size()));
|
|
|
|
|
out.close();
|
|
|
|
|
|
|
|
|
|
std::uniform_real_distribution<float> pitchDist(pitchLo, pitchHi);
|
|
|
|
|
float pitch = pitchDist(rng);
|
2026-02-05 17:32:21 -08:00
|
|
|
volume *= volumeScale;
|
2026-02-19 02:46:52 -08:00
|
|
|
if (volume <= 0.0001f) return true; // Intentionally muted
|
2026-02-03 19:49:56 -08:00
|
|
|
if (volume > 1.2f) volume = 1.2f;
|
|
|
|
|
std::string filter = "asetrate=44100*" + std::to_string(pitch) +
|
|
|
|
|
",aresample=44100,volume=" + std::to_string(volume);
|
|
|
|
|
|
2026-02-03 22:24:17 -08:00
|
|
|
oneShotPid = platform::spawnProcess({
|
|
|
|
|
"-nodisp", "-autoexit", "-loglevel", "quiet",
|
|
|
|
|
"-af", filter, oneShotTempPath
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return oneShotPid != INVALID_PROCESS;
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::startSwimLoop() {
|
2026-02-09 14:50:14 -08:00
|
|
|
// Swimming sounds now handled by periodic playback in update() method
|
|
|
|
|
// This method kept for API compatibility but does nothing
|
|
|
|
|
return;
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::stopSwimLoop() {
|
2026-02-03 22:24:17 -08:00
|
|
|
platform::killProcess(swimLoopPid);
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::stopOneShot() {
|
2026-02-03 22:24:17 -08:00
|
|
|
platform::killProcess(oneShotPid);
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::reapProcesses() {
|
2026-02-03 22:24:17 -08:00
|
|
|
if (oneShotPid != INVALID_PROCESS) {
|
|
|
|
|
platform::isProcessRunning(oneShotPid);
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
2026-02-03 22:24:17 -08:00
|
|
|
if (swimLoopPid != INVALID_PROCESS) {
|
|
|
|
|
platform::isProcessRunning(swimLoopPid);
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::playJump() {
|
2026-02-09 01:01:24 -08:00
|
|
|
if (!AudioEngine::instance().isInitialized() || jumpClips.empty()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-09 00:40:50 -08:00
|
|
|
|
2026-02-03 19:49:56 -08:00
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
|
|
|
if (lastJumpAt.time_since_epoch().count() != 0) {
|
|
|
|
|
if (std::chrono::duration<float>(now - lastJumpAt).count() < 0.35f) return;
|
|
|
|
|
}
|
2026-02-09 01:01:24 -08:00
|
|
|
|
|
|
|
|
// Pick random clip
|
|
|
|
|
std::uniform_int_distribution<size_t> dist(0, jumpClips.size() - 1);
|
|
|
|
|
const Sample& sample = jumpClips[dist(rng)];
|
|
|
|
|
|
|
|
|
|
// Play with slight volume/pitch variation
|
|
|
|
|
std::uniform_real_distribution<float> volumeDist(0.65f, 0.75f);
|
|
|
|
|
std::uniform_real_distribution<float> pitchDist(0.98f, 1.04f);
|
|
|
|
|
float volume = volumeDist(rng) * volumeScale;
|
|
|
|
|
float pitch = pitchDist(rng);
|
|
|
|
|
|
|
|
|
|
if (AudioEngine::instance().playSound2D(sample.data, volume, pitch)) {
|
2026-02-03 19:49:56 -08:00
|
|
|
lastJumpAt = now;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::playLanding(FootstepSurface surface, bool hardLanding) {
|
2026-02-09 01:01:24 -08:00
|
|
|
if (!AudioEngine::instance().isInitialized()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-09 00:40:50 -08:00
|
|
|
|
2026-02-03 19:49:56 -08:00
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
|
|
|
if (lastLandAt.time_since_epoch().count() != 0) {
|
|
|
|
|
if (std::chrono::duration<float>(now - lastLandAt).count() < 0.10f) return;
|
|
|
|
|
}
|
2026-02-09 01:01:24 -08:00
|
|
|
|
2026-02-03 19:49:56 -08:00
|
|
|
const auto& clips = landingSets[static_cast<size_t>(surface)].clips;
|
2026-02-09 01:01:24 -08:00
|
|
|
if (!clips.empty()) {
|
|
|
|
|
std::uniform_int_distribution<size_t> dist(0, clips.size() - 1);
|
|
|
|
|
const Sample& sample = clips[dist(rng)];
|
|
|
|
|
|
|
|
|
|
float baseVolume = hardLanding ? 1.00f : 0.82f;
|
|
|
|
|
std::uniform_real_distribution<float> volumeDist(baseVolume * 0.95f, baseVolume * 1.05f);
|
|
|
|
|
std::uniform_real_distribution<float> pitchDist(0.95f, 1.03f);
|
|
|
|
|
|
|
|
|
|
AudioEngine::instance().playSound2D(sample.data, volumeDist(rng) * volumeScale, pitchDist(rng));
|
2026-02-03 19:49:56 -08:00
|
|
|
lastLandAt = now;
|
|
|
|
|
}
|
2026-02-09 01:01:24 -08:00
|
|
|
|
|
|
|
|
if (hardLanding && !hardLandClips.empty()) {
|
|
|
|
|
std::uniform_int_distribution<size_t> dist(0, hardLandClips.size() - 1);
|
|
|
|
|
const Sample& sample = hardLandClips[dist(rng)];
|
|
|
|
|
std::uniform_real_distribution<float> volumeDist(0.80f, 0.88f);
|
|
|
|
|
std::uniform_real_distribution<float> pitchDist(0.97f, 1.03f);
|
|
|
|
|
AudioEngine::instance().playSound2D(sample.data, volumeDist(rng) * volumeScale, pitchDist(rng));
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 14:01:26 -08:00
|
|
|
void ActivitySoundManager::playMeleeSwing() {
|
2026-02-09 01:01:24 -08:00
|
|
|
if (!AudioEngine::instance().isInitialized() || meleeSwingClips.empty()) {
|
|
|
|
|
if (meleeSwingClips.empty() && !meleeSwingWarned) {
|
2026-02-05 14:01:26 -08:00
|
|
|
core::Logger::getInstance().warning("No melee swing SFX found in assets");
|
|
|
|
|
meleeSwingWarned = true;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-09 01:01:24 -08:00
|
|
|
|
2026-02-05 14:01:26 -08:00
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
|
|
|
if (lastMeleeSwingAt.time_since_epoch().count() != 0) {
|
|
|
|
|
if (std::chrono::duration<float>(now - lastMeleeSwingAt).count() < 0.12f) return;
|
|
|
|
|
}
|
2026-02-09 01:01:24 -08:00
|
|
|
|
|
|
|
|
std::uniform_int_distribution<size_t> dist(0, meleeSwingClips.size() - 1);
|
|
|
|
|
const Sample& sample = meleeSwingClips[dist(rng)];
|
|
|
|
|
|
|
|
|
|
std::uniform_real_distribution<float> volumeDist(0.76f, 0.84f);
|
|
|
|
|
std::uniform_real_distribution<float> pitchDist(0.96f, 1.04f);
|
|
|
|
|
|
|
|
|
|
if (AudioEngine::instance().playSound2D(sample.data, volumeDist(rng) * volumeScale, pitchDist(rng))) {
|
2026-02-05 14:01:26 -08:00
|
|
|
lastMeleeSwingAt = now;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 19:49:56 -08:00
|
|
|
void ActivitySoundManager::setSwimmingState(bool swimming, bool moving) {
|
|
|
|
|
swimMoving = moving;
|
|
|
|
|
if (swimming == swimmingActive) return;
|
|
|
|
|
swimmingActive = swimming;
|
|
|
|
|
if (swimmingActive) {
|
2026-02-09 14:50:14 -08:00
|
|
|
LOG_INFO("Swimming started - playing swim loop");
|
2026-02-03 19:49:56 -08:00
|
|
|
startSwimLoop();
|
|
|
|
|
} else {
|
2026-02-09 14:50:14 -08:00
|
|
|
LOG_INFO("Swimming stopped - stopping swim loop");
|
2026-02-03 19:49:56 -08:00
|
|
|
stopSwimLoop();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::setCharacterVoiceProfile(const std::string& modelName) {
|
|
|
|
|
if (!assetManager || modelName.empty()) return;
|
|
|
|
|
|
|
|
|
|
std::string lower = modelName;
|
|
|
|
|
for (char& c : lower) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
|
|
|
|
|
|
|
|
|
bool male = (lower.find("female") == std::string::npos);
|
|
|
|
|
std::string folder = "Human";
|
|
|
|
|
std::string base = "Human";
|
|
|
|
|
|
|
|
|
|
struct RaceMap { const char* token; const char* folder; const char* base; };
|
|
|
|
|
static const RaceMap races[] = {
|
|
|
|
|
{"human", "Human", "Human"},
|
|
|
|
|
{"orc", "Orc", "Orc"},
|
|
|
|
|
{"dwarf", "Dwarf", "Dwarf"},
|
|
|
|
|
{"nightelf", "NightElf", "NightElf"},
|
|
|
|
|
{"scourge", "Scourge", "Scourge"},
|
|
|
|
|
{"undead", "Scourge", "Scourge"},
|
|
|
|
|
{"tauren", "Tauren", "Tauren"},
|
|
|
|
|
{"gnome", "Gnome", "Gnome"},
|
|
|
|
|
{"troll", "Troll", "Troll"},
|
|
|
|
|
{"bloodelf", "BloodElf", "BloodElf"},
|
|
|
|
|
{"draenei", "Draenei", "Draenei"},
|
|
|
|
|
{"goblin", "Goblin", "Goblin"},
|
|
|
|
|
{"worgen", "Worgen", "Worgen"},
|
|
|
|
|
};
|
|
|
|
|
for (const auto& r : races) {
|
|
|
|
|
if (lower.find(r.token) != std::string::npos) {
|
|
|
|
|
folder = r.folder;
|
|
|
|
|
base = r.base;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string key = folder + "|" + base + "|" + (male ? "M" : "F");
|
|
|
|
|
if (key == voiceProfileKey) return;
|
|
|
|
|
voiceProfileKey = key;
|
|
|
|
|
rebuildJumpClipsForProfile(folder, base, male);
|
|
|
|
|
rebuildSwimLoopClipsForProfile(folder, base, male);
|
|
|
|
|
rebuildHardLandClipsForProfile(folder, base, male);
|
2026-02-19 21:31:37 -08:00
|
|
|
rebuildCombatVocalClipsForProfile(folder, base, male);
|
2026-02-03 19:49:56 -08:00
|
|
|
core::Logger::getInstance().info("Activity SFX voice profile: ", voiceProfileKey,
|
|
|
|
|
" jump clips=", jumpClips.size(),
|
|
|
|
|
" swim clips=", swimLoopClips.size(),
|
2026-02-19 21:31:37 -08:00
|
|
|
" hardLand clips=", hardLandClips.size(),
|
|
|
|
|
" attackGrunt clips=", attackGruntClips.size(),
|
|
|
|
|
" wound clips=", woundClips.size(),
|
|
|
|
|
" death clips=", deathClips.size());
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-23 06:22:30 -08:00
|
|
|
void ActivitySoundManager::setCharacterVoiceProfile(const std::string& raceFolder, const std::string& raceBase, bool male) {
|
|
|
|
|
if (!assetManager) return;
|
|
|
|
|
std::string key = raceFolder + "|" + raceBase + "|" + (male ? "M" : "F");
|
|
|
|
|
if (key == voiceProfileKey) return;
|
|
|
|
|
voiceProfileKey = key;
|
|
|
|
|
rebuildJumpClipsForProfile(raceFolder, raceBase, male);
|
|
|
|
|
rebuildSwimLoopClipsForProfile(raceFolder, raceBase, male);
|
|
|
|
|
rebuildHardLandClipsForProfile(raceFolder, raceBase, male);
|
|
|
|
|
rebuildCombatVocalClipsForProfile(raceFolder, raceBase, male);
|
|
|
|
|
core::Logger::getInstance().info("Activity SFX voice profile (explicit): ", voiceProfileKey,
|
|
|
|
|
" jump clips=", jumpClips.size(),
|
|
|
|
|
" swim clips=", swimLoopClips.size(),
|
|
|
|
|
" hardLand clips=", hardLandClips.size(),
|
|
|
|
|
" attackGrunt clips=", attackGruntClips.size(),
|
|
|
|
|
" wound clips=", woundClips.size(),
|
|
|
|
|
" death clips=", deathClips.size());
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 19:49:56 -08:00
|
|
|
void ActivitySoundManager::playWaterEnter() {
|
2026-02-09 14:50:14 -08:00
|
|
|
LOG_INFO("Water entry detected - attempting to play splash sound");
|
2026-02-03 19:49:56 -08:00
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
|
|
|
if (lastSplashAt.time_since_epoch().count() != 0) {
|
2026-02-09 14:50:14 -08:00
|
|
|
if (std::chrono::duration<float>(now - lastSplashAt).count() < 0.20f) {
|
|
|
|
|
LOG_DEBUG("Water splash throttled (too soon)");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
|
|
|
|
if (playOneShot(splashEnterClips, 0.95f, 0.95f, 1.05f)) {
|
2026-02-09 14:50:14 -08:00
|
|
|
LOG_INFO("Water splash enter sound played");
|
2026-02-03 19:49:56 -08:00
|
|
|
lastSplashAt = now;
|
2026-02-09 14:50:14 -08:00
|
|
|
} else {
|
|
|
|
|
LOG_ERROR("Failed to play water splash enter sound");
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::playWaterExit() {
|
2026-02-09 14:50:14 -08:00
|
|
|
LOG_INFO("Water exit detected - attempting to play splash sound");
|
2026-02-03 19:49:56 -08:00
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
|
|
|
if (lastSplashAt.time_since_epoch().count() != 0) {
|
2026-02-09 14:50:14 -08:00
|
|
|
if (std::chrono::duration<float>(now - lastSplashAt).count() < 0.20f) {
|
|
|
|
|
LOG_DEBUG("Water splash throttled (too soon)");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
|
|
|
|
if (playOneShot(splashExitClips, 0.95f, 0.95f, 1.05f)) {
|
2026-02-09 14:50:14 -08:00
|
|
|
LOG_INFO("Water splash exit sound played");
|
2026-02-03 19:49:56 -08:00
|
|
|
lastSplashAt = now;
|
2026-02-09 14:50:14 -08:00
|
|
|
} else {
|
|
|
|
|
LOG_ERROR("Failed to play water splash exit sound");
|
2026-02-03 19:49:56 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 21:31:37 -08:00
|
|
|
void ActivitySoundManager::rebuildCombatVocalClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male) {
|
|
|
|
|
attackGruntClips.clear();
|
|
|
|
|
woundClips.clear();
|
|
|
|
|
woundCritClips.clear();
|
|
|
|
|
deathClips.clear();
|
|
|
|
|
|
|
|
|
|
const std::string gender = male ? "Male" : "Female";
|
2026-02-19 21:50:32 -08:00
|
|
|
const std::string stem = raceBase + gender; // e.g. HumanFemale
|
|
|
|
|
|
|
|
|
|
// WoW 3.3.5a has two sound sources for player combat vocalizations:
|
|
|
|
|
//
|
|
|
|
|
// 1) Vox files (some races only):
|
|
|
|
|
// Sound\Character\{Race}\{Gender}\m{Race}{Gender}{Type}Vox{Letter}.wav
|
|
|
|
|
// e.g. Sound\Character\Human\Female\mHumanFemaleAttackVoxA.wav
|
|
|
|
|
//
|
|
|
|
|
// 2) PlayerExertions (all races):
|
|
|
|
|
// Sound\Character\PlayerExertions\{Race}{Gender}Final\{Race}{Gender}Main{Type}{Letter}.wav
|
|
|
|
|
// e.g. Sound\Character\PlayerExertions\HumanMaleFinal\HumanMaleMainAttackA.wav
|
|
|
|
|
// EXCEPTIONS:
|
|
|
|
|
// - OrcMale uses folder "OrcMale" (no "Final" suffix)
|
|
|
|
|
// - HumanFemale files have Blizzard typo: "HumanFeamle" instead of "HumanFemale"
|
|
|
|
|
|
|
|
|
|
// Determine PlayerExertions folder and file stem
|
|
|
|
|
std::string exertFolder = stem + "Final";
|
|
|
|
|
std::string exertStem = stem;
|
|
|
|
|
// OrcMale exception: no "Final" suffix on folder
|
|
|
|
|
if (raceBase == "Orc" && male) exertFolder = "OrcMale";
|
|
|
|
|
// HumanFemale exception: Blizzard typo "Feamle"
|
|
|
|
|
if (raceBase == "Human" && !male) exertStem = "HumanFeamle";
|
|
|
|
|
// Undead uses "Scourge" in raceBase but "Undead" in PlayerExertions
|
|
|
|
|
std::string exertRaceBase = raceBase;
|
|
|
|
|
if (raceBase == "Scourge") {
|
|
|
|
|
exertRaceBase = "Undead";
|
|
|
|
|
exertFolder = "Undead" + gender + "Final";
|
|
|
|
|
exertStem = "Undead" + gender;
|
2026-02-19 21:31:37 -08:00
|
|
|
}
|
2026-02-19 21:50:32 -08:00
|
|
|
|
|
|
|
|
const std::string exertPrefix = "Sound\\Character\\PlayerExertions\\" + exertFolder + "\\" + exertStem + "Main";
|
|
|
|
|
const std::string voxPrefix = "Sound\\Character\\" + raceFolder + "\\" + gender + "\\m" + stem;
|
|
|
|
|
|
|
|
|
|
// Attack grunts
|
|
|
|
|
std::vector<std::string> attackPaths;
|
2026-02-19 21:31:37 -08:00
|
|
|
for (char c = 'A'; c <= 'F'; ++c) {
|
|
|
|
|
std::string s(1, c);
|
2026-02-19 21:50:32 -08:00
|
|
|
attackPaths.push_back(exertPrefix + "Attack" + s + ".wav");
|
|
|
|
|
attackPaths.push_back(voxPrefix + "AttackVox" + s + ".wav");
|
2026-02-19 21:31:37 -08:00
|
|
|
}
|
|
|
|
|
preloadCandidates(attackGruntClips, attackPaths);
|
|
|
|
|
|
2026-02-19 21:50:32 -08:00
|
|
|
// Wound sounds
|
2026-02-19 21:31:37 -08:00
|
|
|
std::vector<std::string> woundPaths;
|
2026-02-19 21:50:32 -08:00
|
|
|
for (char c = 'A'; c <= 'F'; ++c) {
|
2026-02-19 21:31:37 -08:00
|
|
|
std::string s(1, c);
|
2026-02-19 21:50:32 -08:00
|
|
|
woundPaths.push_back(exertPrefix + "Wound" + s + ".wav");
|
|
|
|
|
woundPaths.push_back(voxPrefix + "WoundVox" + s + ".wav");
|
2026-02-19 21:31:37 -08:00
|
|
|
}
|
|
|
|
|
preloadCandidates(woundClips, woundPaths);
|
|
|
|
|
|
2026-02-19 21:50:32 -08:00
|
|
|
// Wound crit sounds
|
2026-02-19 21:31:37 -08:00
|
|
|
std::vector<std::string> woundCritPaths;
|
|
|
|
|
for (char c = 'A'; c <= 'C'; ++c) {
|
|
|
|
|
std::string s(1, c);
|
2026-02-19 21:50:32 -08:00
|
|
|
woundCritPaths.push_back(exertPrefix + "WoundCrit" + s + ".wav");
|
|
|
|
|
woundCritPaths.push_back(voxPrefix + "WoundCriticalVox" + s + ".wav");
|
2026-02-19 21:31:37 -08:00
|
|
|
}
|
2026-02-19 21:50:32 -08:00
|
|
|
// Some races have WoundCrit without letter suffix
|
|
|
|
|
woundCritPaths.push_back(exertPrefix + "WoundCrit.wav");
|
2026-02-19 21:31:37 -08:00
|
|
|
preloadCandidates(woundCritClips, woundCritPaths);
|
|
|
|
|
|
|
|
|
|
// Death sounds
|
2026-02-19 21:50:32 -08:00
|
|
|
std::vector<std::string> deathPaths;
|
|
|
|
|
for (char c = 'A'; c <= 'C'; ++c) {
|
|
|
|
|
std::string s(1, c);
|
|
|
|
|
deathPaths.push_back(exertPrefix + "Death" + s + ".wav");
|
|
|
|
|
deathPaths.push_back(voxPrefix + "DeathVox" + s + ".wav");
|
|
|
|
|
}
|
|
|
|
|
preloadCandidates(deathClips, deathPaths);
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Combat vocals for ", stem, ": attack=", attackGruntClips.size(),
|
|
|
|
|
" wound=", woundClips.size(), " woundCrit=", woundCritClips.size(),
|
|
|
|
|
" death=", deathClips.size());
|
|
|
|
|
if (!attackGruntClips.empty()) LOG_INFO(" First attack: ", attackGruntClips[0].path);
|
|
|
|
|
if (!woundClips.empty()) LOG_INFO(" First wound: ", woundClips[0].path);
|
|
|
|
|
if (attackGruntClips.empty() && woundClips.empty()) {
|
|
|
|
|
LOG_WARNING("No combat vocal sounds found for ", stem);
|
|
|
|
|
LOG_WARNING(" Tried exert prefix: ", exertPrefix);
|
|
|
|
|
LOG_WARNING(" Tried vox prefix: ", voxPrefix);
|
|
|
|
|
}
|
2026-02-19 21:31:37 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::playAttackGrunt() {
|
|
|
|
|
if (!AudioEngine::instance().isInitialized() || attackGruntClips.empty()) return;
|
|
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
|
|
|
if (lastAttackGruntAt.time_since_epoch().count() != 0) {
|
|
|
|
|
if (std::chrono::duration<float>(now - lastAttackGruntAt).count() < 1.5f) return;
|
|
|
|
|
}
|
|
|
|
|
// ~30% chance per swing to grunt (not every hit)
|
|
|
|
|
std::uniform_int_distribution<int> chance(0, 9);
|
|
|
|
|
if (chance(rng) > 2) return;
|
|
|
|
|
|
|
|
|
|
std::uniform_int_distribution<size_t> dist(0, attackGruntClips.size() - 1);
|
|
|
|
|
const Sample& sample = attackGruntClips[dist(rng)];
|
|
|
|
|
std::uniform_real_distribution<float> volDist(0.55f, 0.70f);
|
|
|
|
|
std::uniform_real_distribution<float> pitchDist(0.96f, 1.04f);
|
|
|
|
|
if (AudioEngine::instance().playSound2D(sample.data, volDist(rng) * volumeScale, pitchDist(rng))) {
|
|
|
|
|
lastAttackGruntAt = now;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::playWound(bool isCrit) {
|
|
|
|
|
if (!AudioEngine::instance().isInitialized()) return;
|
|
|
|
|
auto& clips = (isCrit && !woundCritClips.empty()) ? woundCritClips : woundClips;
|
|
|
|
|
if (clips.empty()) return;
|
|
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
|
|
|
if (lastWoundAt.time_since_epoch().count() != 0) {
|
|
|
|
|
if (std::chrono::duration<float>(now - lastWoundAt).count() < 0.8f) return;
|
|
|
|
|
}
|
|
|
|
|
std::uniform_int_distribution<size_t> dist(0, clips.size() - 1);
|
|
|
|
|
const Sample& sample = clips[dist(rng)];
|
|
|
|
|
float vol = isCrit ? 0.80f : 0.65f;
|
|
|
|
|
std::uniform_real_distribution<float> pitchDist(0.96f, 1.04f);
|
|
|
|
|
if (AudioEngine::instance().playSound2D(sample.data, vol * volumeScale, pitchDist(rng))) {
|
|
|
|
|
lastWoundAt = now;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ActivitySoundManager::playDeath() {
|
|
|
|
|
if (!AudioEngine::instance().isInitialized() || deathClips.empty()) return;
|
|
|
|
|
std::uniform_int_distribution<size_t> dist(0, deathClips.size() - 1);
|
|
|
|
|
const Sample& sample = deathClips[dist(rng)];
|
|
|
|
|
AudioEngine::instance().playSound2D(sample.data, 0.85f * volumeScale, 1.0f);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 19:49:56 -08:00
|
|
|
} // namespace audio
|
|
|
|
|
} // namespace wowee
|