initial commit

This commit is contained in:
NeilClack
2026-08-07 14:51:41 -05:00
commit 9251b3a196
604 changed files with 464448 additions and 0 deletions
+494
View File
@@ -0,0 +1,494 @@
#include "audio_manager.h"
#include "logger/logger.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <iostream>
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<std::mutex> 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;
}
// Create mixer device with default audio device
mixer_ = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, 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 + ")");
initialized_ = false;
return false;
}
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().info("Audio", "SDL3_mixer audio system initialized successfully");
return true;
}
float AudioManager::effectiveGain() const
{
return enabled_ ? (volume_ / 100.0f) : 0.0f;
}
void AudioManager::shutdown()
{
std::lock_guard<std::mutex> 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<AudioDeviceItem> AudioManager::getPlaybackDevices() const
{
std::vector<AudioDeviceItem> 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 : "";
list.push_back({raw, mapToFriendlyDeviceName(raw)});
}
SDL_free(devices);
}
else
{
list.push_back({"", "Built-In Speakers"});
}
return list;
}
void AudioManager::setVolume(int volume_0_to_100)
{
std::lock_guard<std::mutex> 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<std::mutex> lock(mutex_);
return volume_;
}
void AudioManager::setAudioEnabled(bool enabled)
{
std::lock_guard<std::mutex> 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<std::mutex> lock(mutex_);
return enabled_;
}
bool AudioManager::switchDevice(const std::string &raw_device_name)
{
std::lock_guard<std::mutex> lock(mutex_);
currentDevice_ = raw_device_name;
logger::Logger::instance().info("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(raw_device_name));
if (configManager_)
{
configManager_->update([raw_device_name](config::AppConfig &cfg)
{ cfg.audio.output_device = raw_device_name; });
}
return true;
}
std::string AudioManager::getCurrentDevice() const
{
std::lock_guard<std::mutex> lock(mutex_);
return currentDevice_;
}
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);
MIX_PlayTrack(slot.track, 0); // Loop playback
logger::Logger::instance().info("Audio", "Started playback of " + currentTrack_ + " (fade-in)");
}
bool AudioManager::playTrack(const std::string &wav_filename)
{
std::lock_guard<std::mutex> lock(mutex_);
if (!initialized_ || !mixer_ || !slots_[activeSlot_].track)
return false;
if (!loadTrackInto(activeSlot_, wav_filename))
return false;
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
MIX_PlayTrack(slots_[activeSlot_].track, 0); // 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 " + wav_filename);
return true;
}
std::string AudioManager::currentTrack() const
{
std::lock_guard<std::mutex> lock(mutex_);
return currentTrack_;
}
bool AudioManager::playTrackFadeIn(const std::string &wav_filename, float seconds)
{
std::lock_guard<std::mutex> 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);
if (!MIX_TrackPlaying(slot.track))
{
MIX_PlayTrack(slot.track, 0); // 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<std::mutex> 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.
TrackSlot &active = slots_[activeSlot_];
if (active.audio)
{
MIX_StopTrack(active.track, MIX_TrackMSToFrames(active.track, static_cast<Sint64>(fade * 1000.0)));
active.fadeOutRemaining = fade;
}
activeSlot_ = otherSlot;
startFadeIn(activeSlot_, fade);
return true;
}
bool AudioManager::stopTrackFadeOut(float seconds)
{
std::lock_guard<std::mutex> 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;
}
MIX_StopTrack(slot.track, MIX_TrackMSToFrames(slot.track, static_cast<Sint64>(fade * 1000.0)));
slot.fadeOutRemaining = fade;
faded = true;
}
return faded;
}
void AudioManager::stopTrack()
{
std::lock_guard<std::mutex> 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<std::mutex> lock(mutex_);
if (!initialized_)
return;
for (int i = 0; i < 2; ++i)
{
TrackSlot &slot = slots_[i];
if (!slot.track)
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);
}
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;
}
}
}
}
}
} // namespace radiantwave::audio