Kelsidavis-WoWee/src/audio/activity_sound_manager.cpp
Kelsi 328ec9ea78 Fix combat vocalizations with correct MPQ paths, add combat idle stance
Use actual WoW 3.3.5a PlayerExertions and Vox sound paths from MPQ
manifests for attack grunts, wounds, and death sounds. Handle Blizzard
naming quirks (HumanFeamle typo, OrcMale no Final suffix, Scourge→Undead).
Add COMBAT_IDLE animation state with ready weapon stance between swings.
Restore deleted MPQ sound manifest docs.
2026-02-19 21:50:32 -08:00

564 lines
23 KiB
C++

#include "audio/activity_sound_manager.hpp"
#include "audio/audio_engine.hpp"
#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;
rebuildJumpClipsForProfile("Human", "Human", true);
rebuildSwimLoopClipsForProfile("Human", "Human", true);
rebuildHardLandClipsForProfile("Human", "Human", true);
preloadCandidates(splashEnterClips, {
"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"
});
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");
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"
});
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();
meleeSwingClips.clear();
swimmingActive = false;
swimMoving = false;
initialized = false;
assetManager = nullptr;
}
void ActivitySoundManager::update(float deltaTime) {
reapProcesses();
// 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{};
}
}
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;
// 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";
preloadCandidates(jumpClips, {
// 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
prefix + stem + "JumpA.wav",
prefix + stem + "JumpB.wav",
prefix + stem + "Jump.wav",
prefix + gender + "\\" + stem + "JumpA.wav",
prefix + gender + "\\" + stem + "JumpB.wav",
prefix + stem + "\\" + stem + "Jump01.wav",
prefix + stem + "\\" + stem + "Jump02.wav",
});
}
void ActivitySoundManager::rebuildSwimLoopClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male) {
swimLoopClips.clear();
// WoW 3.3.5a doesn't have dedicated swim loop sounds
// Use water splash/footstep sounds as swimming stroke sounds
preloadCandidates(swimLoopClips, {
"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"
});
}
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;
if (volumeScale <= 0.0001f || volume <= 0.0001f) return true; // Intentionally muted
reapProcesses();
if (oneShotPid != INVALID_PROCESS) return false;
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);
volume *= volumeScale;
if (volume <= 0.0001f) return true; // Intentionally muted
if (volume > 1.2f) volume = 1.2f;
std::string filter = "asetrate=44100*" + std::to_string(pitch) +
",aresample=44100,volume=" + std::to_string(volume);
oneShotPid = platform::spawnProcess({
"-nodisp", "-autoexit", "-loglevel", "quiet",
"-af", filter, oneShotTempPath
});
return oneShotPid != INVALID_PROCESS;
}
void ActivitySoundManager::startSwimLoop() {
// Swimming sounds now handled by periodic playback in update() method
// This method kept for API compatibility but does nothing
return;
}
void ActivitySoundManager::stopSwimLoop() {
platform::killProcess(swimLoopPid);
}
void ActivitySoundManager::stopOneShot() {
platform::killProcess(oneShotPid);
}
void ActivitySoundManager::reapProcesses() {
if (oneShotPid != INVALID_PROCESS) {
platform::isProcessRunning(oneShotPid);
}
if (swimLoopPid != INVALID_PROCESS) {
platform::isProcessRunning(swimLoopPid);
}
}
void ActivitySoundManager::playJump() {
if (!AudioEngine::instance().isInitialized() || jumpClips.empty()) {
return;
}
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;
}
// 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)) {
lastJumpAt = now;
}
}
void ActivitySoundManager::playLanding(FootstepSurface surface, bool hardLanding) {
if (!AudioEngine::instance().isInitialized()) {
return;
}
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;
}
const auto& clips = landingSets[static_cast<size_t>(surface)].clips;
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));
lastLandAt = now;
}
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));
}
}
void ActivitySoundManager::playMeleeSwing() {
if (!AudioEngine::instance().isInitialized() || meleeSwingClips.empty()) {
if (meleeSwingClips.empty() && !meleeSwingWarned) {
core::Logger::getInstance().warning("No melee swing SFX found in assets");
meleeSwingWarned = true;
}
return;
}
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;
}
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))) {
lastMeleeSwingAt = now;
}
}
void ActivitySoundManager::setSwimmingState(bool swimming, bool moving) {
swimMoving = moving;
if (swimming == swimmingActive) return;
swimmingActive = swimming;
if (swimmingActive) {
LOG_INFO("Swimming started - playing swim loop");
startSwimLoop();
} else {
LOG_INFO("Swimming stopped - stopping swim loop");
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);
rebuildCombatVocalClipsForProfile(folder, base, male);
core::Logger::getInstance().info("Activity SFX voice profile: ", 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());
}
void ActivitySoundManager::playWaterEnter() {
LOG_INFO("Water entry detected - attempting to play splash sound");
auto now = std::chrono::steady_clock::now();
if (lastSplashAt.time_since_epoch().count() != 0) {
if (std::chrono::duration<float>(now - lastSplashAt).count() < 0.20f) {
LOG_DEBUG("Water splash throttled (too soon)");
return;
}
}
if (playOneShot(splashEnterClips, 0.95f, 0.95f, 1.05f)) {
LOG_INFO("Water splash enter sound played");
lastSplashAt = now;
} else {
LOG_ERROR("Failed to play water splash enter sound");
}
}
void ActivitySoundManager::playWaterExit() {
LOG_INFO("Water exit detected - attempting to play splash sound");
auto now = std::chrono::steady_clock::now();
if (lastSplashAt.time_since_epoch().count() != 0) {
if (std::chrono::duration<float>(now - lastSplashAt).count() < 0.20f) {
LOG_DEBUG("Water splash throttled (too soon)");
return;
}
}
if (playOneShot(splashExitClips, 0.95f, 0.95f, 1.05f)) {
LOG_INFO("Water splash exit sound played");
lastSplashAt = now;
} else {
LOG_ERROR("Failed to play water splash exit sound");
}
}
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";
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;
}
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;
for (char c = 'A'; c <= 'F'; ++c) {
std::string s(1, c);
attackPaths.push_back(exertPrefix + "Attack" + s + ".wav");
attackPaths.push_back(voxPrefix + "AttackVox" + s + ".wav");
}
preloadCandidates(attackGruntClips, attackPaths);
// Wound sounds
std::vector<std::string> woundPaths;
for (char c = 'A'; c <= 'F'; ++c) {
std::string s(1, c);
woundPaths.push_back(exertPrefix + "Wound" + s + ".wav");
woundPaths.push_back(voxPrefix + "WoundVox" + s + ".wav");
}
preloadCandidates(woundClips, woundPaths);
// Wound crit sounds
std::vector<std::string> woundCritPaths;
for (char c = 'A'; c <= 'C'; ++c) {
std::string s(1, c);
woundCritPaths.push_back(exertPrefix + "WoundCrit" + s + ".wav");
woundCritPaths.push_back(voxPrefix + "WoundCriticalVox" + s + ".wav");
}
// Some races have WoundCrit without letter suffix
woundCritPaths.push_back(exertPrefix + "WoundCrit.wav");
preloadCandidates(woundCritClips, woundCritPaths);
// Death sounds
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);
}
}
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);
}
} // namespace audio
} // namespace wowee