build-dev / nix-build (push) Successful in 45s
recreateMixerOnDevice destroyed the active mixer before opening the
replacement. MIX_DestroyMixer quits SDL audio at refcount 0, tearing down
the device registry and renumbering device ids, so the cached id being
reopened went stale ('Invalid audio device instance ID') and the app went
permanently mute. Open the new mixer first while the old device is alive,
then tear the old one down; a failed switch now keeps the current output
playing instead of killing audio. Also log SDL_GetError() on open failure.
874 lines
30 KiB
C++
874 lines
30 KiB
C++
#include "audio_manager.h"
|
|
#include "db/database_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("virtual") != 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;
|
|
}
|
|
|
|
// Startup diagnostics: which audio backend SDL picked (PipeWire, ALSA,
|
|
// Pulse, dummy, ...) and what playback devices it actually sees. This is
|
|
// the first place a "only the built-in speakers show up" report points;
|
|
// a single device here means SDL itself never enumerated the HDMI sink.
|
|
const char *driver = SDL_GetCurrentAudioDriver();
|
|
std::string driverName = driver ? driver : "(none)";
|
|
int deviceCount = 0;
|
|
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&deviceCount);
|
|
std::string deviceNames;
|
|
if (devices)
|
|
{
|
|
for (int i = 0; i < deviceCount; ++i)
|
|
{
|
|
if (i)
|
|
{
|
|
deviceNames += ", ";
|
|
}
|
|
const char *n = SDL_GetAudioDeviceName(devices[i]);
|
|
deviceNames += n ? n : "(unnamed)";
|
|
}
|
|
SDL_free(devices);
|
|
}
|
|
logger::Logger::instance().info(
|
|
"Audio",
|
|
"SDL audio driver: " + driverName + "; " +
|
|
std::to_string(deviceCount) + " playback device(s) [" +
|
|
(deviceNames.empty() ? "(none)" : deviceNames) + "]");
|
|
|
|
// 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<std::mutex> 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<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 : "";
|
|
AudioDeviceItem item;
|
|
item.raw_name = raw;
|
|
item.friendly_name = mapToFriendlyDeviceName(raw);
|
|
item.id = devices[i];
|
|
list.push_back(item);
|
|
}
|
|
SDL_free(devices);
|
|
}
|
|
else
|
|
{
|
|
if (devices)
|
|
{
|
|
SDL_free(devices);
|
|
}
|
|
const char *driver = SDL_GetCurrentAudioDriver();
|
|
// Zero enumerated devices is a symptom, not a real output. Surface
|
|
// the truth (a single "System Default" entry that still routes to
|
|
// whatever the OS default is) instead of pretending a fabricated
|
|
// device, and say which backend was active so kiosk logs show why.
|
|
logger::Logger::instance().debug(
|
|
"Audio",
|
|
"No playback devices enumerated (audio driver: " +
|
|
std::string(driver ? driver : "none") +
|
|
"); falling back to the system default");
|
|
list.push_back({"", "System Default", 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<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_);
|
|
|
|
// 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<std::mutex> 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<std::mutex> lock(mutex_);
|
|
return currentDevice_;
|
|
}
|
|
|
|
SDL_AudioDeviceID AudioManager::getCurrentDeviceId() const
|
|
{
|
|
std::lock_guard<std::mutex> 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<std::mutex> 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. Creates a fresh mixer on the given
|
|
// device, recreating the two track slots and resuming the active track if
|
|
// one was playing.
|
|
//
|
|
// The new mixer is created BEFORE the current one is destroyed. Reversing
|
|
// the order (destroy-then-reopen) quits the whole SDL audio subsystem the
|
|
// moment the last device closes: MIX_DestroyMixer() follows up with
|
|
// SDL_QuitSubSystem(SDL_INIT_AUDIO), which tears down SDL's device registry
|
|
// and renumbers every enumerated device id on the way back up. The id this
|
|
// switch was asked to open is then stale and the reopen fails with
|
|
// "Invalid audio device instance ID". Keeping the current device open while
|
|
// the replacement opens also means a failed switch leaves the current
|
|
// output playing instead of going permanently mute.
|
|
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;
|
|
|
|
// Open the replacement mixer on the target device first, while the
|
|
// current device (and SDL's device registry) is still alive.
|
|
MIX_Mixer *newMixer = MIX_CreateMixerDevice(targetId, nullptr);
|
|
if (!newMixer)
|
|
{
|
|
std::string err = SDL_GetError();
|
|
logger::Logger::instance().error(
|
|
"Audio",
|
|
"Could not open audio output device "
|
|
+ mapToFriendlyDeviceName(resolvePlaybackDeviceName(targetId))
|
|
+ "; keeping the current device (MIX_CreateMixerDevice error: "
|
|
+ err + ")");
|
|
return false;
|
|
}
|
|
|
|
MIX_Track *newTracks[2];
|
|
for (int i = 0; i < 2; ++i)
|
|
{
|
|
newTracks[i] = MIX_CreateTrack(newMixer);
|
|
}
|
|
if (!newTracks[0] || !newTracks[1])
|
|
{
|
|
MIX_DestroyMixer(newMixer);
|
|
logger::Logger::instance().error(
|
|
"Audio", "Could not create mixer tracks; keeping the current device (SDL error: "
|
|
+ std::string(SDL_GetError()) + ")");
|
|
return false;
|
|
}
|
|
|
|
// Tear down the existing mixer (its tracks die with it) and adopt the
|
|
// new one.
|
|
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_ = newMixer;
|
|
slots_[0].track = newTracks[0];
|
|
slots_[1].track = newTracks[1];
|
|
|
|
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<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);
|
|
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<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);
|
|
// 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<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. 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<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;
|
|
}
|
|
// 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<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;
|
|
}
|
|
|
|
// 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
|