diff --git a/src/audio/audio_manager.cpp b/src/audio/audio_manager.cpp index 7fc16a8..c72b8ef 100644 --- a/src/audio/audio_manager.cpp +++ b/src/audio/audio_manager.cpp @@ -76,7 +76,6 @@ namespace radiantwave::audio enabled_ = cfg.audio.enabled; currentDevice_ = cfg.audio.output_device; currentTrack_ = cfg.audio.current_track; - if (!MIX_Init()) { std::string err = SDL_GetError(); @@ -86,17 +85,40 @@ namespace radiantwave::audio return false; } - // Create mixer device with default audio device - mixer_ = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr); + // Try to open the mixer on the device previously persisted in config; + // fall back to the system default when the configured device is gone. + SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK; + if (!currentDevice_.empty()) + { + int count = 0; + SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count); + if (devices) + { + for (int i = 0; i < count; ++i) + { + const char *name = SDL_GetAudioDeviceName(devices[i]); + if (name && currentDevice_ == name) + { + targetId = devices[i]; + break; + } + } + SDL_free(devices); + } + } + + mixer_ = MIX_CreateMixerDevice(targetId, nullptr); if (!mixer_) { std::string err = SDL_GetError(); logger::Logger::instance().warning("Audio", - "Could not open default audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")"); + "Could not open audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")"); initialized_ = false; return false; } + currentDeviceId_ = targetId; + slots_[0].track = MIX_CreateTrack(mixer_); slots_[1].track = MIX_CreateTrack(mixer_); initialized_ = true; @@ -181,18 +203,48 @@ namespace radiantwave::audio { const char *name = SDL_GetAudioDeviceName(devices[i]); std::string raw = name ? name : ""; - list.push_back({raw, mapToFriendlyDeviceName(raw)}); + AudioDeviceItem item; + item.raw_name = raw; + item.friendly_name = mapToFriendlyDeviceName(raw); + item.id = devices[i]; + list.push_back(item); } SDL_free(devices); } else { - list.push_back({"", "Built-In Speakers"}); + list.push_back({"", "Built-In Speakers", 0}); } return list; } + std::string AudioManager::resolvePlaybackDeviceName(SDL_AudioDeviceID id) + { + if (id == 0 || id == SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK) + { + return ""; + } + + int count = 0; + SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count); + std::string found; + if (devices) + { + for (int i = 0; i < count; ++i) + { + if (devices[i] == id) + { + const char *name = SDL_GetAudioDeviceName(devices[i]); + found = name ? name : ""; + break; + } + } + SDL_free(devices); + } + return found; + } + void AudioManager::setVolume(int volume_0_to_100) { std::lock_guard lock(mutex_); @@ -253,14 +305,76 @@ namespace radiantwave::audio bool AudioManager::switchDevice(const std::string &raw_device_name) { std::lock_guard lock(mutex_); - currentDevice_ = raw_device_name; - logger::Logger::instance().debug("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(raw_device_name)); + // Resolve the config's raw name to an SDL device id so the mixer is + // actually reopened on the requested device (""/unresolvable = default). + SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK; + if (!raw_device_name.empty()) + { + int count = 0; + SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count); + if (devices) + { + for (int i = 0; i < count; ++i) + { + const char *name = SDL_GetAudioDeviceName(devices[i]); + if (name && raw_device_name == name) + { + targetId = devices[i]; + break; + } + } + SDL_free(devices); + } + } + + return switchDeviceById(targetId); + } + + bool AudioManager::switchDeviceById(SDL_AudioDeviceID id) + { + std::lock_guard lock(mutex_); + + // 0 is the synthetic "default / built-in" entry from getPlaybackDevices(). + if (id == 0) + { + id = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK; + } + + // No mixer yet: just record the choice for initialize(). + if (!mixer_ || !initialized_) + { + currentDeviceId_ = id; + currentDevice_ = resolvePlaybackDeviceName(id); + if (configManager_) + { + configManager_->update([name = currentDevice_](config::AppConfig &cfg) + { cfg.audio.output_device = name; }); + } + return true; + } + + if (!recreateMixerOnDevice(id)) + { + logger::Logger::instance().error("Audio", + "Failed to switch audio output device to: " + mapToFriendlyDeviceName(resolvePlaybackDeviceName(id))); + return false; + } + + currentDeviceId_ = id; + // After recreation SDL may have normalized the id (e.g. default). + if (SDL_GetAudioDeviceName(id) == nullptr && id != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK) + { + currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK; + } + currentDevice_ = resolvePlaybackDeviceName(currentDeviceId_); + + logger::Logger::instance().info("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(currentDevice_)); if (configManager_) { - configManager_->update([raw_device_name](config::AppConfig &cfg) - { cfg.audio.output_device = raw_device_name; }); + configManager_->update([name = currentDevice_](config::AppConfig &cfg) + { cfg.audio.output_device = name; }); } return true; @@ -272,6 +386,123 @@ namespace radiantwave::audio return currentDevice_; } + SDL_AudioDeviceID AudioManager::getCurrentDeviceId() const + { + std::lock_guard lock(mutex_); + return currentDeviceId_; + } + + bool AudioManager::handleDeviceEvent(const SDL_Event &event) + { + if (event.type != SDL_EVENT_AUDIO_DEVICE_ADDED && + event.type != SDL_EVENT_AUDIO_DEVICE_REMOVED) + { + return false; + } + + std::lock_guard lock(mutex_); + if (!mixer_ || !initialized_) + { + return false; + } + + // Only playback device events are relevant to the active output. + if (event.adevice.recording) + { + return false; + } + + const SDL_AudioDeviceID changedId = event.adevice.which; + + // If a specific device (not the default) was removed, fail over to the + // system default so playback keeps going on whatever remains. + if (event.type == SDL_EVENT_AUDIO_DEVICE_REMOVED && + currentDeviceId_ != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK && + changedId == currentDeviceId_) + { + logger::Logger::instance().warning("Audio", + "Active audio output device removed; failing over to the system default"); + if (recreateMixerOnDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)) + { + currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK; + currentDevice_ = ""; + } + return true; + } + + // A device arrived or a non-active device left: nothing to remap, but + // the audio settings page refreshes its list on its own timer. + return false; + } + + // Must be called with mutex_ held. Destroys the current mixer and creates a + // fresh one on the given device, recreating the two track slots and + // resuming the active track if one was playing. + bool AudioManager::recreateMixerOnDevice(SDL_AudioDeviceID targetId) + { + // Capture the playback state we want to preserve across the switch. + std::string resumeTrack = currentTrack_; + float fadeSeconds = slots_[activeSlot_].fadeInDuration > 0.0f + ? slots_[activeSlot_].fadeInDuration + : 0.0f; + + // Tear down the existing mixer (its tracks die with it). + for (int i = 0; i < 2; ++i) + { + slots_[i].track = nullptr; + if (slots_[i].audio) + { + MIX_DestroyAudio(slots_[i].audio); + slots_[i].audio = nullptr; + } + } + if (mixer_) + { + MIX_DestroyMixer(mixer_); + mixer_ = nullptr; + } + + mixer_ = MIX_CreateMixerDevice(targetId, nullptr); + if (!mixer_) + { + initialized_ = false; + return false; + } + + slots_[0].track = MIX_CreateTrack(mixer_); + slots_[1].track = MIX_CreateTrack(mixer_); + if (!slots_[0].track || !slots_[1].track) + { + MIX_DestroyMixer(mixer_); + mixer_ = nullptr; + initialized_ = false; + return false; + } + + float gain = enabled_ ? (volume_ / 100.0f) : 0.0f; + MIX_SetMixerGain(mixer_, gain); + + // Resume the previously active track at full gain (or restart its + // fade-in if it was still ramping). + if (!resumeTrack.empty()) + { + if (loadTrackInto(activeSlot_, resumeTrack)) + { + if (fadeSeconds > 0.0f) + { + startFadeIn(activeSlot_, fadeSeconds); + } + else + { + MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f); + MIX_PlayTrack(slots_[activeSlot_].track, 0); // Loop playback + } + } + } + + return true; + } + bool AudioManager::loadTrackInto(int slotIndex, const std::string &wav_filename) { if (!initialized_ || !mixer_ || !slots_[slotIndex].track) diff --git a/src/audio/audio_manager.h b/src/audio/audio_manager.h index fe5760d..404280d 100644 --- a/src/audio/audio_manager.h +++ b/src/audio/audio_manager.h @@ -1,6 +1,7 @@ #pragma once #include "config/config_manager.h" +#include #include #include #include @@ -15,6 +16,7 @@ namespace radiantwave::audio { struct AudioDeviceItem { std::string raw_name; // e.g. alsa_output.pci-0000_00_1f.3.analog-stereo std::string friendly_name; // e.g. "Built-In Speakers" + SDL_AudioDeviceID id = 0; // SDL playback device id (0 = synthetic fallback) }; // Maps raw technical audio device strings to user-friendly labels @@ -51,7 +53,18 @@ public: // Audio device selection bool switchDevice(const std::string& raw_device_name); + bool switchDeviceById(SDL_AudioDeviceID id); std::string getCurrentDevice() const; + SDL_AudioDeviceID getCurrentDeviceId() const; + + // Handle an SDL audio device event (SDL_EVENT_AUDIO_DEVICE_ADDED / REMOVED). + // Returns true if the active device was affected and the mixer was recreated. + bool handleDeviceEvent(const SDL_Event& event); + + // Pure helper (no SDL audio open): find the playback device with the given + // SDL id, returning its raw name ("" when not found / id invalid). Exposed + // for tests; the real lookup is through getPlaybackDevices(). + static std::string resolvePlaybackDeviceName(SDL_AudioDeviceID id); // Playback control bool playTrack(const std::string& wav_filename); @@ -84,6 +97,13 @@ private: void startFadeIn(int slotIndex, float seconds); std::string displayNameFor(const std::string& wav_filename) const; + // Recreates the mixer on the given playback device id (0 = system default). + // Called with mutex_ held. Preserves playback state: whichever track was + // playing on the active slot is reloaded and resumed at full gain, an + // in-progress fade is restarted. Returns false on failure (previous mixer is + // left destroyed but the app remains safe to keep running muted). + bool recreateMixerOnDevice(SDL_AudioDeviceID targetId); + config::ConfigManager* configManager_ = nullptr; db::DatabaseManager* db_ = nullptr; @@ -91,6 +111,7 @@ private: bool enabled_ = true; int volume_ = 80; // 0 - 100 std::string currentDevice_ = ""; + SDL_AudioDeviceID currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK; std::string currentTrack_ = ""; MIX_Mixer* mixer_ = nullptr; diff --git a/src/audio_settings_page.cpp b/src/audio_settings_page.cpp index 73ca32f..7bad55a 100644 --- a/src/audio_settings_page.cpp +++ b/src/audio_settings_page.cpp @@ -77,7 +77,7 @@ void AudioSettingsPage::onPush(AppState& state) { } else if (modalState_ == ModalState::DeviceSelector) { if (!devices_.empty()) { currentDeviceIndex_ = modalSelectedIndex_; - state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name); + applyDeviceSelection(); } modalState_ = ModalState::None; } else if (modalState_ == ModalState::TrackSelector) { @@ -156,7 +156,7 @@ void AudioSettingsPage::onResume(AppState& state) { } else if (modalState_ == ModalState::DeviceSelector) { if (!devices_.empty()) { currentDeviceIndex_ = modalSelectedIndex_; - state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name); + applyDeviceSelection(); } modalState_ = ModalState::None; } else if (modalState_ == ModalState::TrackSelector) { @@ -280,6 +280,19 @@ void AudioSettingsPage::refreshDeviceAndTrackLists() { } } +void AudioSettingsPage::applyDeviceSelection() { + if (!state_) return; + if (currentDeviceIndex_ < 0 || currentDeviceIndex_ >= static_cast(devices_.size())) return; + + const bool ok = state_->audio.switchDeviceById(devices_[currentDeviceIndex_].id); + if (ok) { + statusMessage_ = "Switched to " + devices_[currentDeviceIndex_].friendly_name; + } else { + statusMessage_ = "Device unavailable - using system default"; + } + refreshDeviceAndTrackLists(); +} + void AudioSettingsPage::enableDynamicAudio() { if (!state_) return; state_->config.update([](radiantwave::config::AppConfig& cfg) { @@ -324,7 +337,7 @@ void AudioSettingsPage::handleEvent(const SDL_Event& event) { if (modalState_ == ModalState::DeviceSelector) { if (!devices_.empty()) { currentDeviceIndex_ = modalSelectedIndex_; - state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name); + applyDeviceSelection(); } } else if (modalState_ == ModalState::TrackSelector) { if (modalSelectedIndex_ == 0) { diff --git a/src/audio_settings_page.h b/src/audio_settings_page.h index 7a2d4be..3ac7cd4 100644 --- a/src/audio_settings_page.h +++ b/src/audio_settings_page.h @@ -36,6 +36,10 @@ private: void enableDynamicAudio(); void selectTrack(int trackIdx); + // Applies the currently selected device (devices_[currentDeviceIndex_]) via + // the audio manager and records user-facing success/failure feedback. + void applyDeviceSelection(); + AppState* state_ = nullptr; int selectedItem_ = 0; // 0 = Volume, 1 = Device, 2 = Track, 3 = Status, 4 = Back diff --git a/src/main.cpp b/src/main.cpp index 7cee294..d097fcd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -196,6 +196,13 @@ SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) { state.quitRequested = true; } + if (event->type == SDL_EVENT_AUDIO_DEVICE_ADDED || + event->type == SDL_EVENT_AUDIO_DEVICE_REMOVED) { + // Route device hotplug to the audio manager so an active device that + // disappears is failed over to the system default automatically. + state.audio.handleDeviceEvent(*event); + } + if (event->type == SDL_EVENT_WINDOW_RESIZED) { radiantwave::logger::Logger::instance().info( "Graphics", "Window resized to " + diff --git a/tests/test_audio.cpp b/tests/test_audio.cpp index b14ab1d..04c2828 100644 --- a/tests/test_audio.cpp +++ b/tests/test_audio.cpp @@ -11,6 +11,29 @@ TEST(AudioTest, FriendlyDeviceNameMapping) { EXPECT_EQ(mapToFriendlyDeviceName(""), "Default Audio Output"); } +TEST(AudioTest, ResolvePlaybackDeviceNameHandlesSentinels) { + // The sentinel and 0 mean "system default" and never correspond to a real + // enumerated playback device, so they resolve to the empty (default) name. + EXPECT_EQ(AudioManager::resolvePlaybackDeviceName(0), ""); + EXPECT_EQ(AudioManager::resolvePlaybackDeviceName(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK), ""); +} + +TEST(AudioTest, GetPlaybackDevicesFallsBackWithIdZeroWhenNone) { + AudioManager am; + auto devices = am.getPlaybackDevices(); + // Headless hosts (CI) expose no playback devices, so expect the synthetic + // fallback entry that carries id 0 (the "system default" marker). + if (devices.empty()) { + // No devices and no fallback would be a contract violation. + FAIL() << "getPlaybackDevices() returned an empty list without fallback"; + } + for (const auto& d : devices) { + if (d.raw_name.empty()) { + EXPECT_EQ(d.id, 0u); + } + } +} + TEST(AudioTest, AudioManagerVolumeClamping) { radiantwave::config::ConfigManager config; std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string();