소스 검색

Implement audio system enhancements with channel management and volume controls

Co-authored-by: djeada <[email protected]>
copilot-swe-agent[bot] 1 개월 전
부모
커밋
1877a3bf5d
8개의 변경된 파일530개의 추가작업 그리고 75개의 파일을 삭제
  1. 15 7
      game/audio/AudioEventHandler.cpp
  2. 5 2
      game/audio/AudioEventHandler.h
  3. 203 12
      game/audio/AudioSystem.cpp
  4. 45 9
      game/audio/AudioSystem.h
  5. 18 14
      game/audio/Music.cpp
  6. 57 10
      game/audio/README.md
  7. 17 12
      game/audio/Sound.cpp
  8. 170 9
      ui/qml/SettingsPanel.qml

+ 15 - 7
game/audio/AudioEventHandler.cpp

@@ -8,7 +8,7 @@ namespace Game {
 namespace Audio {
 
 AudioEventHandler::AudioEventHandler(Engine::Core::World *world)
-    : m_world(world), m_initialized(false) {}
+    : m_world(world), m_initialized(false), m_useVoiceCategory(true) {}
 
 AudioEventHandler::~AudioEventHandler() { shutdown(); }
 
@@ -71,6 +71,10 @@ void AudioEventHandler::loadAmbientMusic(Engine::Core::AmbientState state,
   m_ambientMusicMap[state] = musicId;
 }
 
+void AudioEventHandler::setVoiceSoundCategory(bool useVoiceCategory) {
+  m_useVoiceCategory = useVoiceCategory;
+}
+
 void AudioEventHandler::onUnitSelected(
     const Engine::Core::UnitSelectedEvent &event) {
   if (!m_world) {
@@ -89,16 +93,20 @@ void AudioEventHandler::onUnitSelected(
 
   auto it = m_unitVoiceMap.find(unitComponent->unitType);
   if (it != m_unitVoiceMap.end()) {
-    // Throttle: only play sound if enough time has passed OR unit type changed
     auto now = std::chrono::steady_clock::now();
-    auto timeSinceLastSound = std::chrono::duration_cast<std::chrono::milliseconds>(
-        now - m_lastSelectionSoundTime).count();
-    
+    auto timeSinceLastSound =
+        std::chrono::duration_cast<std::chrono::milliseconds>(
+            now - m_lastSelectionSoundTime)
+            .count();
+
     bool shouldPlay = (timeSinceLastSound >= SELECTION_SOUND_COOLDOWN_MS) ||
                       (unitComponent->unitType != m_lastSelectionUnitType);
-    
+
     if (shouldPlay) {
-      AudioSystem::getInstance().playSound(it->second);
+      AudioCategory category =
+          m_useVoiceCategory ? AudioCategory::VOICE : AudioCategory::SFX;
+      AudioSystem::getInstance().playSound(it->second, 1.0f, false, 5,
+                                           category);
       m_lastSelectionSoundTime = now;
       m_lastSelectionUnitType = unitComponent->unitType;
     }

+ 5 - 2
game/audio/AudioEventHandler.h

@@ -28,6 +28,8 @@ public:
   void loadAmbientMusic(Engine::Core::AmbientState state,
                         const std::string &musicId);
 
+  void setVoiceSoundCategory(bool useVoiceCategory);
+
 private:
   void onUnitSelected(const Engine::Core::UnitSelectedEvent &event);
   void
@@ -39,10 +41,11 @@ private:
   std::unordered_map<std::string, std::string> m_unitVoiceMap;
   std::unordered_map<Engine::Core::AmbientState, std::string> m_ambientMusicMap;
 
-  // Throttling for unit selection sounds
+  bool m_useVoiceCategory;
+
   std::chrono::steady_clock::time_point m_lastSelectionSoundTime;
   std::string m_lastSelectionUnitType;
-  static constexpr int SELECTION_SOUND_COOLDOWN_MS = 300; // 300ms cooldown
+  static constexpr int SELECTION_SOUND_COOLDOWN_MS = 300;
 
   Engine::Core::ScopedEventSubscription<Engine::Core::UnitSelectedEvent>
       m_unitSelectedSub;

+ 203 - 12
game/audio/AudioSystem.cpp

@@ -5,7 +5,7 @@
 
 AudioSystem::AudioSystem()
     : isRunning(false), masterVolume(1.0f), soundVolume(1.0f),
-      musicVolume(1.0f) {}
+      musicVolume(1.0f), voiceVolume(1.0f), maxChannels(32) {}
 
 AudioSystem::~AudioSystem() { shutdown(); }
 
@@ -42,14 +42,20 @@ void AudioSystem::shutdown() {
 
   sounds.clear();
   music.clear();
-  activeSounds.clear();
+  soundCategories.clear();
+  activeResources.clear();
+
+  {
+    std::lock_guard<std::mutex> lock(activeSoundsMutex);
+    activeSounds.clear();
+  }
 }
 
 void AudioSystem::playSound(const std::string &soundId, float volume, bool loop,
-                            int priority) {
+                            int priority, AudioCategory category) {
   std::lock_guard<std::mutex> lock(queueMutex);
-  eventQueue.push(
-      AudioEvent(AudioEventType::PLAY_SOUND, soundId, volume, loop, priority));
+  eventQueue.push(AudioEvent(AudioEventType::PLAY_SOUND, soundId, volume, loop,
+                              priority, category));
   queueCondition.notify_one();
 }
 
@@ -74,8 +80,12 @@ void AudioSystem::stopMusic() {
 
 void AudioSystem::setMasterVolume(float volume) {
   masterVolume = std::clamp(volume, 0.0f, 1.0f);
+
   for (auto &sound : sounds) {
-    sound.second->setVolume(masterVolume * soundVolume);
+    auto it = soundCategories.find(sound.first);
+    AudioCategory category =
+        (it != soundCategories.end()) ? it->second : AudioCategory::SFX;
+    sound.second->setVolume(getEffectiveVolume(category, 1.0f));
   }
   for (auto &musicTrack : music) {
     musicTrack.second->setVolume(masterVolume * musicVolume);
@@ -84,18 +94,34 @@ void AudioSystem::setMasterVolume(float volume) {
 
 void AudioSystem::setSoundVolume(float volume) {
   soundVolume = std::clamp(volume, 0.0f, 1.0f);
+
   for (auto &sound : sounds) {
-    sound.second->setVolume(masterVolume * soundVolume);
+    auto it = soundCategories.find(sound.first);
+    if (it != soundCategories.end() && it->second == AudioCategory::SFX) {
+      sound.second->setVolume(getEffectiveVolume(AudioCategory::SFX, 1.0f));
+    }
   }
 }
 
 void AudioSystem::setMusicVolume(float volume) {
   musicVolume = std::clamp(volume, 0.0f, 1.0f);
+
   for (auto &musicTrack : music) {
     musicTrack.second->setVolume(masterVolume * musicVolume);
   }
 }
 
+void AudioSystem::setVoiceVolume(float volume) {
+  voiceVolume = std::clamp(volume, 0.0f, 1.0f);
+
+  for (auto &sound : sounds) {
+    auto it = soundCategories.find(sound.first);
+    if (it != soundCategories.end() && it->second == AudioCategory::VOICE) {
+      sound.second->setVolume(getEffectiveVolume(AudioCategory::VOICE, 1.0f));
+    }
+  }
+}
+
 void AudioSystem::pauseAll() {
   std::lock_guard<std::mutex> lock(queueMutex);
   eventQueue.push(AudioEvent(AudioEventType::PAUSE));
@@ -109,25 +135,76 @@ void AudioSystem::resumeAll() {
 }
 
 bool AudioSystem::loadSound(const std::string &soundId,
-                            const std::string &filePath) {
+                            const std::string &filePath,
+                            AudioCategory category) {
+  if (sounds.find(soundId) != sounds.end()) {
+    return true;
+  }
+
   auto sound = std::make_unique<Sound>(filePath);
-  if (!sound->isLoaded()) {
+  if (!sound || !sound->isLoaded()) {
     return false;
   }
+
   sounds[soundId] = std::move(sound);
+  soundCategories[soundId] = category;
+  activeResources.insert(soundId);
   return true;
 }
 
 bool AudioSystem::loadMusic(const std::string &musicId,
                             const std::string &filePath) {
+  if (music.find(musicId) != music.end()) {
+    return true;
+  }
+
   auto musicTrack = std::make_unique<Music>(filePath);
-  if (!musicTrack->isLoaded()) {
+  if (!musicTrack || !musicTrack->isLoaded()) {
     return false;
   }
+
   music[musicId] = std::move(musicTrack);
+  activeResources.insert(musicId);
   return true;
 }
 
+void AudioSystem::unloadSound(const std::string &soundId) {
+  std::lock_guard<std::mutex> lock(queueMutex);
+  eventQueue.push(AudioEvent(AudioEventType::UNLOAD_RESOURCE, soundId));
+  queueCondition.notify_one();
+}
+
+void AudioSystem::unloadMusic(const std::string &musicId) {
+  std::lock_guard<std::mutex> lock(queueMutex);
+  eventQueue.push(AudioEvent(AudioEventType::UNLOAD_RESOURCE, musicId));
+  queueCondition.notify_one();
+}
+
+void AudioSystem::unloadAllSounds() {
+  std::lock_guard<std::mutex> lock(queueMutex);
+  for (const auto &sound : sounds) {
+    eventQueue.push(AudioEvent(AudioEventType::UNLOAD_RESOURCE, sound.first));
+  }
+  queueCondition.notify_one();
+}
+
+void AudioSystem::unloadAllMusic() {
+  std::lock_guard<std::mutex> lock(queueMutex);
+  for (const auto &musicTrack : music) {
+    eventQueue.push(AudioEvent(AudioEventType::UNLOAD_RESOURCE, musicTrack.first));
+  }
+  queueCondition.notify_one();
+}
+
+void AudioSystem::setMaxChannels(size_t channels) {
+  maxChannels = std::max(size_t(1), channels);
+}
+
+size_t AudioSystem::getActiveChannelCount() const {
+  std::lock_guard<std::mutex> lock(activeSoundsMutex);
+  return activeSounds.size();
+}
+
 void AudioSystem::audioThreadFunc() {
   while (isRunning) {
     std::unique_lock<std::mutex> lock(queueMutex);
@@ -155,8 +232,19 @@ void AudioSystem::processEvent(const AudioEvent &event) {
   case AudioEventType::PLAY_SOUND: {
     auto it = sounds.find(event.resourceId);
     if (it != sounds.end()) {
-      it->second->play(masterVolume * soundVolume * event.volume, event.loop);
-      activeSounds.push_back({event.resourceId, event.priority, event.loop});
+      if (!canPlaySound(event.priority)) {
+        evictLowestPrioritySound();
+      }
+
+      float effectiveVol = getEffectiveVolume(event.category, event.volume);
+      it->second->play(effectiveVol, event.loop);
+
+      {
+        std::lock_guard<std::mutex> lock(activeSoundsMutex);
+        activeSounds.push_back({event.resourceId, event.priority, event.loop,
+                                event.category,
+                                std::chrono::steady_clock::now()});
+      }
     }
     break;
   }
@@ -174,6 +262,8 @@ void AudioSystem::processEvent(const AudioEvent &event) {
     auto it = sounds.find(event.resourceId);
     if (it != sounds.end()) {
       it->second->stop();
+
+      std::lock_guard<std::mutex> lock(activeSoundsMutex);
       activeSounds.erase(std::remove_if(activeSounds.begin(),
                                         activeSounds.end(),
                                         [&](const ActiveSound &as) {
@@ -201,8 +291,109 @@ void AudioSystem::processEvent(const AudioEvent &event) {
     }
     break;
   }
+  case AudioEventType::UNLOAD_RESOURCE: {
+    auto soundIt = sounds.find(event.resourceId);
+    if (soundIt != sounds.end()) {
+      soundIt->second->stop();
+
+      std::lock_guard<std::mutex> lock(activeSoundsMutex);
+      activeSounds.erase(std::remove_if(activeSounds.begin(),
+                                        activeSounds.end(),
+                                        [&](const ActiveSound &as) {
+                                          return as.id == event.resourceId;
+                                        }),
+                         activeSounds.end());
+
+      sounds.erase(soundIt);
+      soundCategories.erase(event.resourceId);
+      activeResources.erase(event.resourceId);
+    }
+
+    auto musicIt = music.find(event.resourceId);
+    if (musicIt != music.end()) {
+      musicIt->second->stop();
+      music.erase(musicIt);
+      activeResources.erase(event.resourceId);
+    }
+    break;
+  }
+  case AudioEventType::CLEANUP_INACTIVE: {
+    cleanupInactiveSounds();
+    break;
+  }
   case AudioEventType::SET_VOLUME:
   case AudioEventType::SHUTDOWN:
     break;
   }
 }
+
+bool AudioSystem::canPlaySound(int priority) {
+  std::lock_guard<std::mutex> lock(activeSoundsMutex);
+  return activeSounds.size() < maxChannels;
+}
+
+void AudioSystem::evictLowestPrioritySound() {
+  std::lock_guard<std::mutex> lock(activeSoundsMutex);
+
+  if (activeSounds.empty()) {
+    return;
+  }
+
+  auto lowestIt = std::min_element(
+      activeSounds.begin(), activeSounds.end(),
+      [](const ActiveSound &a, const ActiveSound &b) {
+        if (a.priority != b.priority) {
+          return a.priority < b.priority;
+        }
+        return a.startTime < b.startTime;
+      });
+
+  if (lowestIt != activeSounds.end()) {
+    auto it = sounds.find(lowestIt->id);
+    if (it != sounds.end()) {
+      it->second->stop();
+    }
+    activeSounds.erase(lowestIt);
+  }
+}
+
+void AudioSystem::cleanupInactiveSounds() {
+  std::lock_guard<std::mutex> lock(activeSoundsMutex);
+
+  activeSounds.erase(
+      std::remove_if(activeSounds.begin(), activeSounds.end(),
+                     [this](const ActiveSound &as) {
+                       if (as.loop) {
+                         return false;
+                       }
+
+                       auto it = sounds.find(as.id);
+                       if (it == sounds.end()) {
+                         return true;
+                       }
+
+                       return false;
+                     }),
+      activeSounds.end());
+}
+
+float AudioSystem::getEffectiveVolume(AudioCategory category,
+                                      float eventVolume) const {
+  float categoryVolume;
+  switch (category) {
+  case AudioCategory::SFX:
+    categoryVolume = soundVolume;
+    break;
+  case AudioCategory::VOICE:
+    categoryVolume = voiceVolume;
+    break;
+  case AudioCategory::MUSIC:
+    categoryVolume = musicVolume;
+    break;
+  default:
+    categoryVolume = soundVolume;
+    break;
+  }
+
+  return masterVolume * categoryVolume * eventVolume;
+}

+ 45 - 9
game/audio/AudioSystem.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include <atomic>
+#include <chrono>
 #include <condition_variable>
 #include <memory>
 #include <mutex>
@@ -8,6 +9,7 @@
 #include <string>
 #include <thread>
 #include <unordered_map>
+#include <unordered_set>
 #include <vector>
 
 class Sound;
@@ -21,19 +23,26 @@ enum class AudioEventType {
   SET_VOLUME,
   PAUSE,
   RESUME,
-  SHUTDOWN
+  SHUTDOWN,
+  UNLOAD_RESOURCE,
+  CLEANUP_INACTIVE
 };
 
+enum class AudioCategory { SFX, VOICE, MUSIC };
+
 struct AudioEvent {
   AudioEventType type;
   std::string resourceId;
   float volume = 1.0f;
   bool loop = false;
   int priority = 0;
+  AudioCategory category = AudioCategory::SFX;
 
   AudioEvent(AudioEventType t, const std::string &id = "", float vol = 1.0f,
-             bool l = false, int p = 0)
-      : type(t), resourceId(id), volume(vol), loop(l), priority(p) {}
+             bool l = false, int p = 0,
+             AudioCategory cat = AudioCategory::SFX)
+      : type(t), resourceId(id), volume(vol), loop(l), priority(p),
+        category(cat) {}
 };
 
 class AudioSystem {
@@ -44,7 +53,8 @@ public:
   void shutdown();
 
   void playSound(const std::string &soundId, float volume = 1.0f,
-                 bool loop = false, int priority = 0);
+                 bool loop = false, int priority = 0,
+                 AudioCategory category = AudioCategory::SFX);
   void playMusic(const std::string &musicId, float volume = 1.0f,
                  bool crossfade = true);
   void stopSound(const std::string &soundId);
@@ -52,11 +62,25 @@ public:
   void setMasterVolume(float volume);
   void setSoundVolume(float volume);
   void setMusicVolume(float volume);
+  void setVoiceVolume(float volume);
   void pauseAll();
   void resumeAll();
 
-  bool loadSound(const std::string &soundId, const std::string &filePath);
+  bool loadSound(const std::string &soundId, const std::string &filePath,
+                 AudioCategory category = AudioCategory::SFX);
   bool loadMusic(const std::string &musicId, const std::string &filePath);
+  void unloadSound(const std::string &soundId);
+  void unloadMusic(const std::string &musicId);
+  void unloadAllSounds();
+  void unloadAllMusic();
+
+  void setMaxChannels(size_t maxChannels);
+  size_t getActiveChannelCount() const;
+
+  float getMasterVolume() const { return masterVolume; }
+  float getSoundVolume() const { return soundVolume; }
+  float getMusicVolume() const { return musicVolume; }
+  float getVoiceVolume() const { return voiceVolume; }
 
 private:
   AudioSystem();
@@ -67,24 +91,36 @@ private:
 
   void audioThreadFunc();
   void processEvent(const AudioEvent &event);
+  void cleanupInactiveSounds();
+  bool canPlaySound(int priority);
+  void evictLowestPrioritySound();
+  float getEffectiveVolume(AudioCategory category, float eventVolume) const;
 
   std::unordered_map<std::string, std::unique_ptr<Sound>> sounds;
   std::unordered_map<std::string, std::unique_ptr<Music>> music;
+  std::unordered_map<std::string, AudioCategory> soundCategories;
+  std::unordered_set<std::string> activeResources;
 
   std::thread audioThread;
   std::queue<AudioEvent> eventQueue;
-  std::mutex queueMutex;
+  mutable std::mutex queueMutex;
   std::condition_variable queueCondition;
   std::atomic<bool> isRunning;
 
-  float masterVolume;
-  float soundVolume;
-  float musicVolume;
+  std::atomic<float> masterVolume;
+  std::atomic<float> soundVolume;
+  std::atomic<float> musicVolume;
+  std::atomic<float> voiceVolume;
+
+  size_t maxChannels;
 
   struct ActiveSound {
     std::string id;
     int priority;
     bool loop;
+    AudioCategory category;
+    std::chrono::steady_clock::time_point startTime;
   };
   std::vector<ActiveSound> activeSounds;
+  mutable std::mutex activeSoundsMutex;
 };

+ 18 - 14
game/audio/Music.cpp

@@ -10,10 +10,12 @@ Music::Music(const std::string &filePath)
     : filepath(filePath), loaded(false), audioOutput(nullptr),
       mainThread(nullptr), playing(false) {
 
-  if (QCoreApplication::instance()) {
-    mainThread = QCoreApplication::instance()->thread();
+  if (!QCoreApplication::instance()) {
+    return;
   }
 
+  mainThread = QCoreApplication::instance()->thread();
+
   player = std::make_unique<QMediaPlayer>();
 
   if (mainThread && QThread::currentThread() != mainThread) {
@@ -21,10 +23,9 @@ Music::Music(const std::string &filePath)
   }
 
 #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
-  // Create audio output once and set it as a child of player
   audioOutput = new QAudioOutput(player.get());
   player->setAudioOutput(audioOutput);
-  
+
   player->setSource(QUrl::fromLocalFile(QString::fromStdString(filePath)));
   loaded = (player->error() == QMediaPlayer::NoError);
 #else
@@ -45,17 +46,20 @@ void Music::cleanupPlayer() {
     player->stop();
     player.reset();
   } else {
-
     QMediaPlayer *rawPlayer = player.release();
-    QMetaObject::invokeMethod(
-        QCoreApplication::instance(),
-        [rawPlayer]() {
-          if (rawPlayer) {
-            rawPlayer->stop();
-            delete rawPlayer;
-          }
-        },
-        Qt::QueuedConnection);
+    if (QCoreApplication::instance()) {
+      QMetaObject::invokeMethod(
+          QCoreApplication::instance(),
+          [rawPlayer]() {
+            if (rawPlayer) {
+              rawPlayer->stop();
+              delete rawPlayer;
+            }
+          },
+          Qt::QueuedConnection);
+    } else {
+      delete rawPlayer;
+    }
   }
 }
 

+ 57 - 10
game/audio/README.md

@@ -8,9 +8,12 @@ The audio system provides thread-safe audio playback for sounds and music using
 - **Thread-Safe**: Event queue with dedicated audio thread
 - **Sound Effects**: Short sounds with priority and looping support
 - **Music Streams**: Background music with crossfade capability
-- **Volume Controls**: Master, sound, and music volume controls
-- **Resource Management**: Load and manage multiple audio files
+- **Volume Controls**: Master, sound, music, and voice volume controls
+- **Resource Management**: Load and manage multiple audio files with dynamic loading/unloading
 - **Event Integration**: Automatic audio responses to game events
+- **Channel Management**: Configurable max channels with priority-based sound eviction
+- **Audio Categories**: Separate volume control for SFX, Voice, and Music
+- **Memory Optimization**: Dynamic resource loading and unloading for efficient memory usage
 
 ## Components
 
@@ -40,15 +43,31 @@ handler.initialize();
 ### Loading Audio
 
 ```cpp
-// Load sound effects
-audioSystem.loadSound("archer_voice", "assets/audio/voices/archer_voice.wav");
-audioSystem.loadSound("explosion", "assets/sounds/explosion.wav");
+// Load sound effects with categories
+audioSystem.loadSound("archer_voice", "assets/audio/voices/archer_voice.wav", AudioCategory::VOICE);
+audioSystem.loadSound("explosion", "assets/sounds/explosion.wav", AudioCategory::SFX);
 
 // Load background music
 audioSystem.loadMusic("peaceful", "assets/audio/music/peaceful.wav");
 audioSystem.loadMusic("combat", "assets/audio/music/combat.wav");
 ```
 
+### Dynamic Resource Management
+
+```cpp
+// Unload specific resources when no longer needed
+audioSystem.unloadSound("explosion");
+audioSystem.unloadMusic("old_track");
+
+// Bulk unload for level transitions
+audioSystem.unloadAllSounds();
+audioSystem.unloadAllMusic();
+
+// Configure channel limits
+audioSystem.setMaxChannels(32);
+size_t activeChannels = audioSystem.getActiveChannelCount();
+```
+
 ### Configuring Event Mappings
 
 ```cpp
@@ -64,8 +83,9 @@ handler.loadAmbientMusic(Engine::Core::AmbientState::COMBAT, "combat");
 ### Playing Audio
 
 ```cpp
-// Direct playback
-audioSystem.playSound("explosion", 1.0f, false, 10);
+// Direct playback with category
+audioSystem.playSound("explosion", 1.0f, false, 10, AudioCategory::SFX);
+audioSystem.playSound("unit_voice", 1.0f, false, 5, AudioCategory::VOICE);
 audioSystem.playMusic("theme", 0.8f, true);
 
 // Event-triggered playback
@@ -87,6 +107,15 @@ audioSystem.setSoundVolume(0.7f);
 
 // Set music volume
 audioSystem.setMusicVolume(0.6f);
+
+// Set voice volume (separate from SFX)
+audioSystem.setVoiceVolume(0.8f);
+
+// Get current volume levels
+float master = audioSystem.getMasterVolume();
+float sfx = audioSystem.getSoundVolume();
+float music = audioSystem.getMusicVolume();
+float voice = audioSystem.getVoiceVolume();
 ```
 
 ### Stopping Audio
@@ -169,8 +198,26 @@ Current audio files are simple sine wave tones for testing. Replace with actual
 ### AudioSystem
 - Thread-safe event queue for audio commands
 - Dedicated audio thread for non-blocking playback
-- Resource management for sounds and music
-- Volume controls with proper mixing
+- Resource management for sounds and music with dynamic loading/unloading
+- Volume controls with proper mixing for Master, SFX, Music, and Voice
+- Channel management with configurable limits
+- Priority-based sound eviction when channel limit is reached
+- Memory-safe cleanup on shutdown
+- Atomic volume controls for thread safety
+
+### Channel Management
+The system implements intelligent channel management:
+- Configurable maximum simultaneous sounds (default: 32 channels)
+- Priority-based eviction when channel limit is reached
+- Lower priority sounds are stopped to make room for higher priority sounds
+- Non-looping sounds are automatically cleaned up when finished
+- Active channel count tracking for monitoring
+
+### Audio Categories
+Three distinct audio categories with independent volume control:
+- **SFX**: Sound effects (explosions, impacts, etc.)
+- **VOICE**: Unit voices and dialogue
+- **MUSIC**: Background music and themes
 
 ### AudioEventHandler
 - Subscribes to game events via EventManager
@@ -211,5 +258,5 @@ Note: Dedicated test executables are not currently part of the build configurati
 - [ ] Voice line queuing to prevent overlaps
 - [ ] Audio resource hot-reloading
 - [ ] Audio pooling for frequently used sounds
-- [ ] Compression and streaming optimization
+- [ ] Fade-in/fade-out effects for smooth transitions
 

+ 17 - 12
game/audio/Sound.cpp

@@ -8,10 +8,12 @@
 Sound::Sound(const std::string &filePath)
     : filepath(filePath), loaded(false), mainThread(nullptr) {
 
-  if (QCoreApplication::instance()) {
-    mainThread = QCoreApplication::instance()->thread();
+  if (!QCoreApplication::instance()) {
+    return;
   }
 
+  mainThread = QCoreApplication::instance()->thread();
+
   soundEffect = std::make_unique<QSoundEffect>();
 
   if (mainThread && QThread::currentThread() != mainThread) {
@@ -35,17 +37,20 @@ void Sound::cleanupSoundEffect() {
     soundEffect->stop();
     soundEffect.reset();
   } else {
-
     QSoundEffect *rawEffect = soundEffect.release();
-    QMetaObject::invokeMethod(
-        QCoreApplication::instance(),
-        [rawEffect]() {
-          if (rawEffect) {
-            rawEffect->stop();
-            delete rawEffect;
-          }
-        },
-        Qt::QueuedConnection);
+    if (QCoreApplication::instance()) {
+      QMetaObject::invokeMethod(
+          QCoreApplication::instance(),
+          [rawEffect]() {
+            if (rawEffect) {
+              rawEffect->stop();
+              delete rawEffect;
+            }
+          },
+          Qt::QueuedConnection);
+    } else {
+      delete rawEffect;
+    }
   }
 }
 

+ 170 - 9
ui/qml/SettingsPanel.qml

@@ -68,6 +68,176 @@ Item {
                 color: Theme.border
             }
 
+            ColumnLayout {
+                Layout.fillWidth: true
+                spacing: Theme.spacingMedium
+
+                Label {
+                    text: qsTr("Audio Settings")
+                    color: Theme.textMain
+                    font.pointSize: Theme.fontSizeLarge
+                    font.bold: true
+                }
+
+                Rectangle {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: 2
+                    color: Theme.border
+                    opacity: 0.5
+                }
+
+                GridLayout {
+                    Layout.fillWidth: true
+                    columns: 2
+                    rowSpacing: Theme.spacingMedium
+                    columnSpacing: Theme.spacingMedium
+
+                    Label {
+                        text: qsTr("Master Volume:")
+                        color: Theme.textSub
+                        font.pointSize: Theme.fontSizeMedium
+                    }
+
+                    RowLayout {
+                        Layout.fillWidth: true
+                        spacing: Theme.spacingSmall
+
+                        Slider {
+                            id: masterVolumeSlider
+
+                            Layout.fillWidth: true
+                            from: 0
+                            to: 100
+                            value: 100
+                            stepSize: 1
+                            onValueChanged: {
+                                if (typeof gameEngine !== 'undefined' && gameEngine.audioSystem) {
+                                    gameEngine.audioSystem.setMasterVolume(value / 100.0);
+                                }
+                            }
+                        }
+
+                        Label {
+                            text: Math.round(masterVolumeSlider.value) + "%"
+                            color: Theme.textSub
+                            font.pointSize: Theme.fontSizeMedium
+                            Layout.minimumWidth: 45
+                        }
+
+                    }
+
+                    Label {
+                        text: qsTr("Music Volume:")
+                        color: Theme.textSub
+                        font.pointSize: Theme.fontSizeMedium
+                    }
+
+                    RowLayout {
+                        Layout.fillWidth: true
+                        spacing: Theme.spacingSmall
+
+                        Slider {
+                            id: musicVolumeSlider
+
+                            Layout.fillWidth: true
+                            from: 0
+                            to: 100
+                            value: 100
+                            stepSize: 1
+                            onValueChanged: {
+                                if (typeof gameEngine !== 'undefined' && gameEngine.audioSystem) {
+                                    gameEngine.audioSystem.setMusicVolume(value / 100.0);
+                                }
+                            }
+                        }
+
+                        Label {
+                            text: Math.round(musicVolumeSlider.value) + "%"
+                            color: Theme.textSub
+                            font.pointSize: Theme.fontSizeMedium
+                            Layout.minimumWidth: 45
+                        }
+
+                    }
+
+                    Label {
+                        text: qsTr("SFX Volume:")
+                        color: Theme.textSub
+                        font.pointSize: Theme.fontSizeMedium
+                    }
+
+                    RowLayout {
+                        Layout.fillWidth: true
+                        spacing: Theme.spacingSmall
+
+                        Slider {
+                            id: sfxVolumeSlider
+
+                            Layout.fillWidth: true
+                            from: 0
+                            to: 100
+                            value: 100
+                            stepSize: 1
+                            onValueChanged: {
+                                if (typeof gameEngine !== 'undefined' && gameEngine.audioSystem) {
+                                    gameEngine.audioSystem.setSoundVolume(value / 100.0);
+                                }
+                            }
+                        }
+
+                        Label {
+                            text: Math.round(sfxVolumeSlider.value) + "%"
+                            color: Theme.textSub
+                            font.pointSize: Theme.fontSizeMedium
+                            Layout.minimumWidth: 45
+                        }
+
+                    }
+
+                    Label {
+                        text: qsTr("Voice Volume:")
+                        color: Theme.textSub
+                        font.pointSize: Theme.fontSizeMedium
+                    }
+
+                    RowLayout {
+                        Layout.fillWidth: true
+                        spacing: Theme.spacingSmall
+
+                        Slider {
+                            id: voiceVolumeSlider
+
+                            Layout.fillWidth: true
+                            from: 0
+                            to: 100
+                            value: 100
+                            stepSize: 1
+                            onValueChanged: {
+                                if (typeof gameEngine !== 'undefined' && gameEngine.audioSystem) {
+                                    gameEngine.audioSystem.setVoiceVolume(value / 100.0);
+                                }
+                            }
+                        }
+
+                        Label {
+                            text: Math.round(voiceVolumeSlider.value) + "%"
+                            color: Theme.textSub
+                            font.pointSize: Theme.fontSizeMedium
+                            Layout.minimumWidth: 45
+                        }
+
+                    }
+
+                }
+
+            }
+
+            Rectangle {
+                Layout.fillWidth: true
+                Layout.preferredHeight: 1
+                color: Theme.border
+            }
+
             ColumnLayout {
                 Layout.fillWidth: true
                 spacing: Theme.spacingMedium
@@ -154,15 +324,6 @@ Item {
                 Layout.fillHeight: true
             }
 
-            Label {
-                Layout.fillWidth: true
-                text: qsTr("More settings coming soon...")
-                color: Theme.textSub
-                font.pointSize: Theme.fontSizeSmall
-                opacity: 0.6
-                horizontalAlignment: Text.AlignHCenter
-            }
-
         }
 
     }