#include "audio_manager.h" #include "db/database_manager.h" #include "logger/logger.h" #include #include #include #include #include namespace radiantwave::audio { std::string mapToFriendlyDeviceName(const std::string &raw_name) { if (raw_name.empty()) return "Default Audio Output"; std::string lower = raw_name; std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return std::tolower(c); }); if (lower.find("hdmi") != std::string::npos || lower.find("displayport") != std::string::npos || lower.find("radeon") != std::string::npos || lower.find("nvidia") != std::string::npos || lower.find("tv") != std::string::npos) { return "TV / HDMI Monitor"; } if (lower.find("analog") != std::string::npos || lower.find("built-in") != std::string::npos || lower.find("builtin") != std::string::npos || lower.find("realtek") != std::string::npos || lower.find("hda") != std::string::npos || lower.find("speaker") != std::string::npos || lower.find("pci") != std::string::npos) { return "Built-In Speakers"; } if (lower.find("usb") != std::string::npos || lower.find("headset") != std::string::npos || lower.find("dongle") != std::string::npos) { return "USB Audio Device"; } if (lower.find("bluez") != std::string::npos || lower.find("bluetooth") != std::string::npos) { return "Bluetooth Audio"; } // Fallback cleanly return "Audio Device (" + raw_name.substr(0, 20) + ")"; } // ─── AudioManager Implementation ─────────────────────────────────────── AudioManager::AudioManager() = default; AudioManager::~AudioManager() { shutdown(); } bool AudioManager::initialize(config::ConfigManager &config) { std::lock_guard lock(mutex_); configManager_ = &config; auto cfg = config.get(); volume_ = cfg.audio.volume; enabled_ = cfg.audio.enabled; currentDevice_ = cfg.audio.output_device; currentTrack_ = cfg.audio.current_track; if (!MIX_Init()) { std::string err = SDL_GetError(); logger::Logger::instance().warning("Audio", "Audio library initialization failed. Sound will be muted until hardware is available (MIX_Init error: " + err + ")"); initialized_ = false; return false; } // 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 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; // Apply initial volume float gain = enabled_ ? (volume_ / 100.0f) : 0.0f; MIX_SetMixerGain(mixer_, gain); logger::Logger::instance().debug("Audio", "SDL3_mixer audio system initialized successfully"); return true; } void AudioManager::attachDatabase(db::DatabaseManager& db) { std::lock_guard lock(mutex_); db_ = &db; } std::string AudioManager::displayNameFor(const std::string& wav_filename) const { if (db_) { for (const auto& record : db_->getAudioFiles()) { if (record.filename == wav_filename) { return record.display_name; } } } return wav_filename; } float AudioManager::effectiveGain() const { return enabled_ ? (volume_ / 100.0f) : 0.0f; } SDL_PropertiesID AudioManager::createInfiniteLoopOptions() { // MIX_PlayTrack()'s options default to a single pass; requesting // infinite loops is what keeps a track going until it is explicitly // faded out or the app shuts down. SDL_PropertiesID props = SDL_CreateProperties(); if (props) { SDL_SetNumberProperty(props, MIX_PROP_PLAY_LOOPS_NUMBER, -1); } return props; } bool AudioManager::playTrackLooping(int slotIndex) { if (!initialized_ || !mixer_ || slotIndex < 0 || slotIndex >= 2 || !slots_[slotIndex].track) { return false; } SDL_PropertiesID props = createInfiniteLoopOptions(); if (!props) { logger::Logger::instance().error( "Audio", "Failed to create playback options for " + displayNameFor(currentTrack_)); return false; } bool ok = MIX_PlayTrack(slots_[slotIndex].track, props); SDL_DestroyProperties(props); return ok; } void AudioManager::shutdown() { std::lock_guard lock(mutex_); // Tracks are owned by the mixer: MIX_DestroyMixer() stops and destroys // every track created for it, so MIX_DestroyTrack must not be called // here (it can race the mixer thread's deferred-destroy path). Only the // MIX_Audio objects are ours to release; each track holds its own // reference to the audio attached to it, and any audio left behind is // reclaimed by MIX_Quit(). for (int i = 0; i < 2; ++i) { if (slots_[i].audio) { MIX_DestroyAudio(slots_[i].audio); slots_[i].audio = nullptr; } slots_[i].track = nullptr; } if (mixer_) { MIX_DestroyMixer(mixer_); mixer_ = nullptr; } if (initialized_) { MIX_Quit(); initialized_ = false; } } std::vector AudioManager::getPlaybackDevices() const { std::vector list; int count = 0; SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count); if (devices && count > 0) { for (int i = 0; i < count; ++i) { const char *name = SDL_GetAudioDeviceName(devices[i]); std::string raw = name ? name : ""; 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", 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_); volume_ = std::clamp(volume_0_to_100, 0, 100); if (mixer_) { float gain = enabled_ ? (volume_ / 100.0f) : 0.0f; MIX_SetMixerGain(mixer_, gain); } if (configManager_) { configManager_->update([this](config::AppConfig &cfg) { cfg.audio.volume = volume_; }); } } int AudioManager::getVolume() const { std::lock_guard lock(mutex_); return volume_; } void AudioManager::setAudioEnabled(bool enabled) { std::lock_guard lock(mutex_); enabled_ = enabled; if (mixer_) { float gain = enabled_ ? (volume_ / 100.0f) : 0.0f; MIX_SetMixerGain(mixer_, gain); } if (enabled_) { logger::Logger::instance().info("Audio", "Audio unmuted"); } else { logger::Logger::instance().info("Audio", "Audio muted"); } if (configManager_) { configManager_->update([enabled](config::AppConfig &cfg) { cfg.audio.enabled = enabled; }); } } bool AudioManager::isAudioEnabled() const { std::lock_guard lock(mutex_); return enabled_; } bool AudioManager::switchDevice(const std::string &raw_device_name) { std::lock_guard lock(mutex_); // 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([name = currentDevice_](config::AppConfig &cfg) { cfg.audio.output_device = name; }); } return true; } std::string AudioManager::getCurrentDevice() const { std::lock_guard lock(mutex_); 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); playTrackLooping(activeSlot_); // Loop playback } } } return true; } bool AudioManager::loadTrackInto(int slotIndex, const std::string &wav_filename) { if (!initialized_ || !mixer_ || !slots_[slotIndex].track) return false; // Look for audio file in ~/.local/radiantwave/audio/ or assets/audio/ std::string path1 = std::string(std::getenv("HOME") ? std::getenv("HOME") : "/tmp") + "/.local/radiantwave/audio/" + wav_filename; std::string path2 = "assets/audio/" + wav_filename; std::string chosen_path = std::filesystem::exists(path1) ? path1 : path2; if (!std::filesystem::exists(chosen_path)) { logger::Logger::instance().warning("Audio", "Audio track file is missing. Please ensure audio assets are downloaded (File not found: " + wav_filename + ")"); return false; } MIX_Audio *audio = MIX_LoadAudio(mixer_, chosen_path.c_str(), true); if (!audio) { logger::Logger::instance().error("Audio", "Failed to load audio track (MIX_LoadAudio error: " + std::string(SDL_GetError()) + ")"); return false; } // Stop and free whatever was on this slot before. MIX_StopTrack(slots_[slotIndex].track, 0); if (slots_[slotIndex].audio) { MIX_DestroyAudio(slots_[slotIndex].audio); } slots_[slotIndex].audio = audio; slots_[slotIndex].fadeInDuration = 0.0f; slots_[slotIndex].fadeInElapsed = 0.0f; slots_[slotIndex].fadeOutRemaining = 0.0f; MIX_SetTrackAudio(slots_[slotIndex].track, audio); currentTrack_ = wav_filename; return true; } void AudioManager::startFadeIn(int slotIndex, float seconds) { TrackSlot &slot = slots_[slotIndex]; slot.fadeInDuration = seconds; slot.fadeInElapsed = 0.0f; MIX_SetTrackGain(slot.track, 0.0f); playTrackLooping(slotIndex); // Loop playback logger::Logger::instance().info("Audio", "Started playback of " + displayNameFor(currentTrack_) + " (fade-in)"); } bool AudioManager::playTrack(const std::string &wav_filename) { std::lock_guard lock(mutex_); if (!initialized_ || !mixer_ || !slots_[activeSlot_].track) return false; if (!loadTrackInto(activeSlot_, wav_filename)) return false; MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f); playTrackLooping(activeSlot_); // Loop playback // Explicit user selection: persist the manual track so it survives a // restart and is used whenever dynamic audio is turned off. Dynamic // switches never write here, so they cannot clobber this choice. if (configManager_) { configManager_->update([wav_filename](config::AppConfig &cfg) { cfg.audio.current_track = wav_filename; }); } logger::Logger::instance().info("Audio", "Started playback of " + displayNameFor(wav_filename)); return true; } std::string AudioManager::currentTrack() const { std::lock_guard lock(mutex_); return currentTrack_; } bool AudioManager::playTrackFadeIn(const std::string &wav_filename, float seconds) { std::lock_guard lock(mutex_); if (!initialized_ || !mixer_ || !slots_[activeSlot_].track) return false; // Same track already loaded in the active slot: bring it straight back // to full volume instead of reloading. This avoids an audible glitch // when re-entering the scroller over a track that is still loaded. if (!wav_filename.empty() && wav_filename == currentTrack_ && slots_[activeSlot_].audio) { TrackSlot &slot = slots_[activeSlot_]; slot.fadeInDuration = 0.0f; slot.fadeInElapsed = 0.0f; slot.fadeOutRemaining = 0.0f; MIX_SetTrackGain(slot.track, 1.0f); // Always restart: MIX_PlayTrack() re-starts a playing track too, so // this cancels any in-flight stop-fade inherited from a quick // pop->push within the fade window. Gating on MIX_TrackPlaying() // left a fading track to finish its fade and go silently stopped. playTrackLooping(activeSlot_); // Loop playback return true; } if (!loadTrackInto(activeSlot_, wav_filename)) return false; startFadeIn(activeSlot_, seconds > 0.0f ? seconds : 1.0f); return true; } bool AudioManager::playTrackCrossfade(const std::string &wav_filename, float seconds) { std::lock_guard lock(mutex_); if (!initialized_ || !mixer_) return false; // Not if the track is the same: the current track keeps playing // untouched instead of being crossfaded back into itself. if (!wav_filename.empty() && wav_filename == currentTrack_) { return true; } float fade = seconds > 0.0f ? seconds : 1.0f; int otherSlot = 1 - activeSlot_; TrackSlot &other = slots_[otherSlot]; if (!other.track) return false; // Load the new track into the inactive slot first; on failure keep the // current track playing untouched. if (!loadTrackInto(otherSlot, wav_filename)) return false; // Fade out whatever is currently playing on the active slot. Clear any // in-flight fade-in bookkeeping first so update() stops ramping this // slot's gain up while the mixer fades it down. TrackSlot &active = slots_[activeSlot_]; if (active.audio) { active.fadeInDuration = 0.0f; active.fadeInElapsed = 0.0f; MIX_StopTrack(active.track, MIX_TrackMSToFrames(active.track, static_cast(fade * 1000.0))); active.fadeOutRemaining = fade; } activeSlot_ = otherSlot; startFadeIn(activeSlot_, fade); return true; } bool AudioManager::stopTrackFadeOut(float seconds) { std::lock_guard lock(mutex_); if (!initialized_) return false; // Fade both slots so an in-flight crossfade cannot leave the other // track audible after the fade. Returns false when nothing is playing, // letting callers skip any waiting tied to the fade. float fade = seconds > 0.0f ? seconds : 1.0f; bool faded = false; for (int i = 0; i < 2; ++i) { TrackSlot &slot = slots_[i]; if (!slot.track || !slot.audio) { continue; } // The mixer owns the ramp now; drop any pending fade-in so the // per-frame update() cannot fight the fade-out. slot.fadeInDuration = 0.0f; slot.fadeInElapsed = 0.0f; MIX_StopTrack(slot.track, MIX_TrackMSToFrames(slot.track, static_cast(fade * 1000.0))); slot.fadeOutRemaining = fade; faded = true; } return faded; } void AudioManager::stopTrack() { std::lock_guard lock(mutex_); if (slots_[activeSlot_].track) { MIX_StopTrack(slots_[activeSlot_].track, 0); } if (slots_[activeSlot_].audio) { MIX_DestroyAudio(slots_[activeSlot_].audio); slots_[activeSlot_].audio = nullptr; } slots_[activeSlot_].fadeInDuration = 0.0f; slots_[activeSlot_].fadeInElapsed = 0.0f; slots_[activeSlot_].fadeOutRemaining = 0.0f; } void AudioManager::update(float dt) { std::lock_guard lock(mutex_); if (!initialized_) return; for (int i = 0; i < 2; ++i) { TrackSlot &slot = slots_[i]; if (!slot.track) { continue; } // A slot mid stop-fade belongs to the mixer: skip both the fade-in // ramp and the active-gain pin so nothing drives its gain up while // the mixer ramps it down (this also keeps a quit/pop fade clean). if (slot.fadeOutRemaining > 0.0f) { slot.fadeOutRemaining -= dt; if (slot.fadeOutRemaining <= 0.0f) { slot.fadeOutRemaining = 0.0f; // Free the audio once the fade-out has finished; the track // object itself is reused. if (slot.audio) { MIX_DestroyAudio(slot.audio); slot.audio = nullptr; } } continue; } if (slot.fadeInDuration > 0.0f) { slot.fadeInElapsed += dt; float t = std::min(1.0f, slot.fadeInElapsed / slot.fadeInDuration); MIX_SetTrackGain(slot.track, t); if (t >= 1.0f) { slot.fadeInDuration = 0.0f; } } else if (i == activeSlot_) { // Keep the active track at full per-track gain; the mixer gain // carries the user's volume / mute state. MIX_SetTrackGain(slot.track, 1.0f); // Self-heal: playback must continue indefinitely, so restart a // track that stopped unexpectedly (there is no pause state and // nothing else should legitimately stop the active track). if (slot.audio && enabled_ && !MIX_TrackPlaying(slot.track)) { playTrackLooping(i); } } } } } // namespace radiantwave::audio