Compare commits
6
Commits
1.2
..
2917d33866
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2917d33866 | ||
|
|
3f6201182e | ||
|
|
d84307bd0e | ||
|
|
43cd93bbd7 | ||
|
|
92ba9f39fb | ||
|
|
400c6a782b |
@@ -66,3 +66,17 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
|
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
|
||||||
attic push radiantwave:radiantwave-dev ./result-pkg
|
attic push radiantwave:radiantwave-dev ./result-pkg
|
||||||
|
|
||||||
|
# Publish the manifest kiosks poll for live app updates. `storePath`
|
||||||
|
# must match exactly the closure pushed to the dev Attic cache above
|
||||||
|
# (readlink resolves the result-pkg out-link symlink to its real
|
||||||
|
# /nix/store path), so a kiosk can `nix copy --from` it without ever
|
||||||
|
# building from source. Upload mirrors the ISO scp pattern to the
|
||||||
|
# fileserver via the build-server's `radiantwave-infra` SSH alias.
|
||||||
|
- name: Publish manifest
|
||||||
|
run: |
|
||||||
|
STOREPATH=$(readlink -f ./result-pkg)
|
||||||
|
printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json
|
||||||
|
cat manifest.json
|
||||||
|
scp manifest.json \
|
||||||
|
radiantwave-infra:/mnt/infrastructure_data/files/radiantwave-dev/manifest.json
|
||||||
|
|||||||
+318
-31
@@ -76,7 +76,6 @@ namespace radiantwave::audio
|
|||||||
enabled_ = cfg.audio.enabled;
|
enabled_ = cfg.audio.enabled;
|
||||||
currentDevice_ = cfg.audio.output_device;
|
currentDevice_ = cfg.audio.output_device;
|
||||||
currentTrack_ = cfg.audio.current_track;
|
currentTrack_ = cfg.audio.current_track;
|
||||||
|
|
||||||
if (!MIX_Init())
|
if (!MIX_Init())
|
||||||
{
|
{
|
||||||
std::string err = SDL_GetError();
|
std::string err = SDL_GetError();
|
||||||
@@ -86,17 +85,40 @@ namespace radiantwave::audio
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create mixer device with default audio device
|
// Try to open the mixer on the device previously persisted in config;
|
||||||
mixer_ = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr);
|
// 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_)
|
if (!mixer_)
|
||||||
{
|
{
|
||||||
std::string err = SDL_GetError();
|
std::string err = SDL_GetError();
|
||||||
logger::Logger::instance().warning("Audio",
|
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;
|
initialized_ = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentDeviceId_ = targetId;
|
||||||
|
|
||||||
slots_[0].track = MIX_CreateTrack(mixer_);
|
slots_[0].track = MIX_CreateTrack(mixer_);
|
||||||
slots_[1].track = MIX_CreateTrack(mixer_);
|
slots_[1].track = MIX_CreateTrack(mixer_);
|
||||||
initialized_ = true;
|
initialized_ = true;
|
||||||
@@ -135,6 +157,40 @@ namespace radiantwave::audio
|
|||||||
return enabled_ ? (volume_ / 100.0f) : 0.0f;
|
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()
|
void AudioManager::shutdown()
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
@@ -181,18 +237,48 @@ namespace radiantwave::audio
|
|||||||
{
|
{
|
||||||
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
||||||
std::string raw = name ? name : "";
|
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);
|
SDL_free(devices);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
list.push_back({"", "Built-In Speakers"});
|
list.push_back({"", "Built-In Speakers", 0});
|
||||||
}
|
}
|
||||||
|
|
||||||
return list;
|
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)
|
void AudioManager::setVolume(int volume_0_to_100)
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
@@ -253,14 +339,76 @@ namespace radiantwave::audio
|
|||||||
bool AudioManager::switchDevice(const std::string &raw_device_name)
|
bool AudioManager::switchDevice(const std::string &raw_device_name)
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> 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<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_)
|
if (configManager_)
|
||||||
{
|
{
|
||||||
configManager_->update([raw_device_name](config::AppConfig &cfg)
|
configManager_->update([name = currentDevice_](config::AppConfig &cfg)
|
||||||
{ cfg.audio.output_device = raw_device_name; });
|
{ cfg.audio.output_device = name; });
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -272,6 +420,123 @@ namespace radiantwave::audio
|
|||||||
return currentDevice_;
|
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. 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)
|
bool AudioManager::loadTrackInto(int slotIndex, const std::string &wav_filename)
|
||||||
{
|
{
|
||||||
if (!initialized_ || !mixer_ || !slots_[slotIndex].track)
|
if (!initialized_ || !mixer_ || !slots_[slotIndex].track)
|
||||||
@@ -322,7 +587,7 @@ namespace radiantwave::audio
|
|||||||
slot.fadeInDuration = seconds;
|
slot.fadeInDuration = seconds;
|
||||||
slot.fadeInElapsed = 0.0f;
|
slot.fadeInElapsed = 0.0f;
|
||||||
MIX_SetTrackGain(slot.track, 0.0f);
|
MIX_SetTrackGain(slot.track, 0.0f);
|
||||||
MIX_PlayTrack(slot.track, 0); // Loop playback
|
playTrackLooping(slotIndex); // Loop playback
|
||||||
logger::Logger::instance().info("Audio", "Started playback of " + displayNameFor(currentTrack_) + " (fade-in)");
|
logger::Logger::instance().info("Audio", "Started playback of " + displayNameFor(currentTrack_) + " (fade-in)");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +601,7 @@ namespace radiantwave::audio
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
|
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
|
||||||
MIX_PlayTrack(slots_[activeSlot_].track, 0); // Loop playback
|
playTrackLooping(activeSlot_); // Loop playback
|
||||||
|
|
||||||
// Explicit user selection: persist the manual track so it survives a
|
// Explicit user selection: persist the manual track so it survives a
|
||||||
// restart and is used whenever dynamic audio is turned off. Dynamic
|
// restart and is used whenever dynamic audio is turned off. Dynamic
|
||||||
@@ -373,10 +638,11 @@ namespace radiantwave::audio
|
|||||||
slot.fadeInElapsed = 0.0f;
|
slot.fadeInElapsed = 0.0f;
|
||||||
slot.fadeOutRemaining = 0.0f;
|
slot.fadeOutRemaining = 0.0f;
|
||||||
MIX_SetTrackGain(slot.track, 1.0f);
|
MIX_SetTrackGain(slot.track, 1.0f);
|
||||||
if (!MIX_TrackPlaying(slot.track))
|
// Always restart: MIX_PlayTrack() re-starts a playing track too, so
|
||||||
{
|
// this cancels any in-flight stop-fade inherited from a quick
|
||||||
MIX_PlayTrack(slot.track, 0); // Loop playback
|
// 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,10 +677,14 @@ namespace radiantwave::audio
|
|||||||
if (!loadTrackInto(otherSlot, wav_filename))
|
if (!loadTrackInto(otherSlot, wav_filename))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Fade out whatever is currently playing on the active slot.
|
// 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_];
|
TrackSlot &active = slots_[activeSlot_];
|
||||||
if (active.audio)
|
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)));
|
MIX_StopTrack(active.track, MIX_TrackMSToFrames(active.track, static_cast<Sint64>(fade * 1000.0)));
|
||||||
active.fadeOutRemaining = fade;
|
active.fadeOutRemaining = fade;
|
||||||
}
|
}
|
||||||
@@ -442,6 +712,10 @@ namespace radiantwave::audio
|
|||||||
{
|
{
|
||||||
continue;
|
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)));
|
MIX_StopTrack(slot.track, MIX_TrackMSToFrames(slot.track, static_cast<Sint64>(fade * 1000.0)));
|
||||||
slot.fadeOutRemaining = fade;
|
slot.fadeOutRemaining = fade;
|
||||||
faded = true;
|
faded = true;
|
||||||
@@ -476,7 +750,29 @@ namespace radiantwave::audio
|
|||||||
{
|
{
|
||||||
TrackSlot &slot = slots_[i];
|
TrackSlot &slot = slots_[i];
|
||||||
if (!slot.track)
|
if (!slot.track)
|
||||||
|
{
|
||||||
continue;
|
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)
|
if (slot.fadeInDuration > 0.0f)
|
||||||
{
|
{
|
||||||
@@ -493,21 +789,12 @@ namespace radiantwave::audio
|
|||||||
// Keep the active track at full per-track gain; the mixer gain
|
// Keep the active track at full per-track gain; the mixer gain
|
||||||
// carries the user's volume / mute state.
|
// carries the user's volume / mute state.
|
||||||
MIX_SetTrackGain(slot.track, 1.0f);
|
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
|
||||||
if (slot.fadeOutRemaining > 0.0f)
|
// nothing else should legitimately stop the active track).
|
||||||
{
|
if (slot.audio && enabled_ && !MIX_TrackPlaying(slot.track))
|
||||||
slot.fadeOutRemaining -= dt;
|
|
||||||
if (slot.fadeOutRemaining <= 0.0f)
|
|
||||||
{
|
{
|
||||||
slot.fadeOutRemaining = 0.0f;
|
playTrackLooping(i);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "config/config_manager.h"
|
#include "config/config_manager.h"
|
||||||
|
#include <SDL3/SDL.h>
|
||||||
#include <SDL3_mixer/SDL_mixer.h>
|
#include <SDL3_mixer/SDL_mixer.h>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -15,6 +16,7 @@ namespace radiantwave::audio {
|
|||||||
struct AudioDeviceItem {
|
struct AudioDeviceItem {
|
||||||
std::string raw_name; // e.g. alsa_output.pci-0000_00_1f.3.analog-stereo
|
std::string raw_name; // e.g. alsa_output.pci-0000_00_1f.3.analog-stereo
|
||||||
std::string friendly_name; // e.g. "Built-In Speakers"
|
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
|
// Maps raw technical audio device strings to user-friendly labels
|
||||||
@@ -51,7 +53,24 @@ public:
|
|||||||
|
|
||||||
// Audio device selection
|
// Audio device selection
|
||||||
bool switchDevice(const std::string& raw_device_name);
|
bool switchDevice(const std::string& raw_device_name);
|
||||||
|
bool switchDeviceById(SDL_AudioDeviceID id);
|
||||||
std::string getCurrentDevice() const;
|
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);
|
||||||
|
|
||||||
|
// Builds the SDL_PropertiesID passed to MIX_PlayTrack() for tracks that
|
||||||
|
// loop forever (MIX_PROP_PLAY_LOOPS_NUMBER = -1). Exposed for tests; no
|
||||||
|
// mixer, track, or audio device is required. The caller owns the property
|
||||||
|
// set and must release it with SDL_DestroyProperties().
|
||||||
|
static SDL_PropertiesID createInfiniteLoopOptions();
|
||||||
|
|
||||||
// Playback control
|
// Playback control
|
||||||
bool playTrack(const std::string& wav_filename);
|
bool playTrack(const std::string& wav_filename);
|
||||||
@@ -84,6 +103,18 @@ private:
|
|||||||
void startFadeIn(int slotIndex, float seconds);
|
void startFadeIn(int slotIndex, float seconds);
|
||||||
std::string displayNameFor(const std::string& wav_filename) const;
|
std::string displayNameFor(const std::string& wav_filename) const;
|
||||||
|
|
||||||
|
// Starts the track on the given slot with infinite looping. The track must
|
||||||
|
// already be loaded on the slot. Calling this on a playing/stopped/fading
|
||||||
|
// track restarts it (cancelling any in-flight stop-fade).
|
||||||
|
bool playTrackLooping(int slotIndex);
|
||||||
|
|
||||||
|
// 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;
|
config::ConfigManager* configManager_ = nullptr;
|
||||||
db::DatabaseManager* db_ = nullptr;
|
db::DatabaseManager* db_ = nullptr;
|
||||||
|
|
||||||
@@ -91,6 +122,7 @@ private:
|
|||||||
bool enabled_ = true;
|
bool enabled_ = true;
|
||||||
int volume_ = 80; // 0 - 100
|
int volume_ = 80; // 0 - 100
|
||||||
std::string currentDevice_ = "";
|
std::string currentDevice_ = "";
|
||||||
|
SDL_AudioDeviceID currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||||
std::string currentTrack_ = "";
|
std::string currentTrack_ = "";
|
||||||
|
|
||||||
MIX_Mixer* mixer_ = nullptr;
|
MIX_Mixer* mixer_ = nullptr;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ void AudioSettingsPage::onPush(AppState& state) {
|
|||||||
} else if (modalState_ == ModalState::DeviceSelector) {
|
} else if (modalState_ == ModalState::DeviceSelector) {
|
||||||
if (!devices_.empty()) {
|
if (!devices_.empty()) {
|
||||||
currentDeviceIndex_ = modalSelectedIndex_;
|
currentDeviceIndex_ = modalSelectedIndex_;
|
||||||
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
|
applyDeviceSelection();
|
||||||
}
|
}
|
||||||
modalState_ = ModalState::None;
|
modalState_ = ModalState::None;
|
||||||
} else if (modalState_ == ModalState::TrackSelector) {
|
} else if (modalState_ == ModalState::TrackSelector) {
|
||||||
@@ -156,7 +156,7 @@ void AudioSettingsPage::onResume(AppState& state) {
|
|||||||
} else if (modalState_ == ModalState::DeviceSelector) {
|
} else if (modalState_ == ModalState::DeviceSelector) {
|
||||||
if (!devices_.empty()) {
|
if (!devices_.empty()) {
|
||||||
currentDeviceIndex_ = modalSelectedIndex_;
|
currentDeviceIndex_ = modalSelectedIndex_;
|
||||||
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
|
applyDeviceSelection();
|
||||||
}
|
}
|
||||||
modalState_ = ModalState::None;
|
modalState_ = ModalState::None;
|
||||||
} else if (modalState_ == ModalState::TrackSelector) {
|
} else if (modalState_ == ModalState::TrackSelector) {
|
||||||
@@ -280,6 +280,19 @@ void AudioSettingsPage::refreshDeviceAndTrackLists() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AudioSettingsPage::applyDeviceSelection() {
|
||||||
|
if (!state_) return;
|
||||||
|
if (currentDeviceIndex_ < 0 || currentDeviceIndex_ >= static_cast<int>(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() {
|
void AudioSettingsPage::enableDynamicAudio() {
|
||||||
if (!state_) return;
|
if (!state_) return;
|
||||||
state_->config.update([](radiantwave::config::AppConfig& cfg) {
|
state_->config.update([](radiantwave::config::AppConfig& cfg) {
|
||||||
@@ -324,7 +337,7 @@ void AudioSettingsPage::handleEvent(const SDL_Event& event) {
|
|||||||
if (modalState_ == ModalState::DeviceSelector) {
|
if (modalState_ == ModalState::DeviceSelector) {
|
||||||
if (!devices_.empty()) {
|
if (!devices_.empty()) {
|
||||||
currentDeviceIndex_ = modalSelectedIndex_;
|
currentDeviceIndex_ = modalSelectedIndex_;
|
||||||
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
|
applyDeviceSelection();
|
||||||
}
|
}
|
||||||
} else if (modalState_ == ModalState::TrackSelector) {
|
} else if (modalState_ == ModalState::TrackSelector) {
|
||||||
if (modalSelectedIndex_ == 0) {
|
if (modalSelectedIndex_ == 0) {
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ private:
|
|||||||
void enableDynamicAudio();
|
void enableDynamicAudio();
|
||||||
void selectTrack(int trackIdx);
|
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;
|
AppState* state_ = nullptr;
|
||||||
int selectedItem_ = 0; // 0 = Volume, 1 = Device, 2 = Track, 3 = Status, 4 = Back
|
int selectedItem_ = 0; // 0 = Volume, 1 = Device, 2 = Track, 3 = Status, 4 = Back
|
||||||
|
|
||||||
|
|||||||
+17
-5
@@ -7,6 +7,7 @@
|
|||||||
#include "logical_size.h"
|
#include "logical_size.h"
|
||||||
#include "logger/logger.h"
|
#include "logger/logger.h"
|
||||||
#include "provisioning.h"
|
#include "provisioning.h"
|
||||||
|
#include "scroller_page.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// How long audio fades out before the app tears down on quit. Only applied
|
// How long audio fades out before the app tears down on quit. Only applied
|
||||||
@@ -195,17 +196,28 @@ SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
|
|||||||
state.quitRequested = true;
|
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) {
|
if (event->type == SDL_EVENT_WINDOW_RESIZED) {
|
||||||
radiantwave::logger::Logger::instance().info(
|
radiantwave::logger::Logger::instance().info(
|
||||||
"Graphics", "Window resized to " +
|
"Graphics", "Window resized to " +
|
||||||
std::to_string(event->window.data1) + "x" +
|
std::to_string(event->window.data1) + "x" +
|
||||||
std::to_string(event->window.data2));
|
std::to_string(event->window.data2));
|
||||||
// Logical presentation persists across resizes; re-apply defensively
|
// Logical presentation persists across resizes; re-apply defensively
|
||||||
// in case backend state was reset underneath us.
|
// in case backend state was reset underneath us. Skip while the
|
||||||
SDL_SetRenderLogicalPresentation(state.renderer,
|
// scroller is active — it owns its own presentation (NONE, full
|
||||||
radiantwave::kDesignWidth,
|
// window) and restores LETTERBOX in onPop.
|
||||||
radiantwave::kDesignHeight,
|
if (!dynamic_cast<ScrollerPage *>(state.pages.current())) {
|
||||||
SDL_LOGICAL_PRESENTATION_LETTERBOX);
|
SDL_SetRenderLogicalPresentation(state.renderer,
|
||||||
|
radiantwave::kDesignWidth,
|
||||||
|
radiantwave::kDesignHeight,
|
||||||
|
SDL_LOGICAL_PRESENTATION_LETTERBOX);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.keybinds.handleEvent(*event)) {
|
if (!state.keybinds.handleEvent(*event)) {
|
||||||
|
|||||||
+624
-691
File diff suppressed because it is too large
Load Diff
@@ -115,4 +115,8 @@ private:
|
|||||||
float velocityDelay_ = 2.5f;
|
float velocityDelay_ = 2.5f;
|
||||||
int velocityState_ = 0;
|
int velocityState_ = 0;
|
||||||
float velocityTimer_ = 0.0f;
|
float velocityTimer_ = 0.0f;
|
||||||
|
|
||||||
|
int savedLogicalW_ = 0;
|
||||||
|
int savedLogicalH_ = 0;
|
||||||
|
SDL_RendererLogicalPresentation savedMode_ = SDL_LOGICAL_PRESENTATION_DISABLED;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,29 @@ TEST(AudioTest, FriendlyDeviceNameMapping) {
|
|||||||
EXPECT_EQ(mapToFriendlyDeviceName(""), "Default Audio Output");
|
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) {
|
TEST(AudioTest, AudioManagerVolumeClamping) {
|
||||||
radiantwave::config::ConfigManager config;
|
radiantwave::config::ConfigManager config;
|
||||||
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string();
|
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string();
|
||||||
@@ -36,3 +59,14 @@ TEST(AudioTest, AudioManagerVolumeClamping) {
|
|||||||
am.shutdown();
|
am.shutdown();
|
||||||
std::filesystem::remove(temp_path);
|
std::filesystem::remove(temp_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(AudioTest, PlayOptionsLoopInfinite) {
|
||||||
|
// Regression: MIX_PlayTrack() must not default to a single pass. Tracks
|
||||||
|
// have to loop forever so playback never runs out mid-scroll; a track that
|
||||||
|
// stops (rather than attenuates) also can't be revived by raising app
|
||||||
|
// volume. Assert the options request infinite looping (-1).
|
||||||
|
SDL_PropertiesID props = AudioManager::createInfiniteLoopOptions();
|
||||||
|
ASSERT_NE(props, 0u);
|
||||||
|
EXPECT_EQ(SDL_GetNumberProperty(props, MIX_PROP_PLAY_LOOPS_NUMBER, 0), -1);
|
||||||
|
SDL_DestroyProperties(props);
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,62 @@ TEST(ScrollerStreamsTest, InterleavesSelectedWithDefault)
|
|||||||
EXPECT_EQ(streams[3].audioFilename, "OriginalAudio.wav");
|
EXPECT_EQ(streams[3].audioFilename, "OriginalAudio.wav");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(ScrollerStreamsTest, InterleavedListCyclesBackToFirstSelected)
|
||||||
|
{
|
||||||
|
// Three selected proclamations: the built list must be
|
||||||
|
// [S1, Default, S2, Default, S3, Default]. ScrollerPage advances through
|
||||||
|
// this list with a modulo, so once the trailing default finishes the cycle
|
||||||
|
// restarts at the FIRST selected proclamation — the "always on" default
|
||||||
|
// must never accumulate or take over the queue.
|
||||||
|
ProclamationRecord b;
|
||||||
|
b.id = "B";
|
||||||
|
b.title = "B";
|
||||||
|
b.content = "bbbb";
|
||||||
|
b.selected = true;
|
||||||
|
b.solfeggio_frequency_id = 2;
|
||||||
|
|
||||||
|
ProclamationRecord c;
|
||||||
|
c.id = "C";
|
||||||
|
c.title = "C";
|
||||||
|
c.content = "cccc";
|
||||||
|
c.selected = true;
|
||||||
|
c.solfeggio_frequency_id = 3;
|
||||||
|
|
||||||
|
ProclamationRecord d;
|
||||||
|
d.id = "D";
|
||||||
|
d.title = "D";
|
||||||
|
d.content = "dddd";
|
||||||
|
d.selected = true;
|
||||||
|
d.solfeggio_frequency_id = 4;
|
||||||
|
|
||||||
|
ProclamationRecord def;
|
||||||
|
def.id = kDefaultId;
|
||||||
|
def.title = "Default";
|
||||||
|
def.content = "dddddddddd";
|
||||||
|
def.selected = true;
|
||||||
|
def.solfeggio_frequency_id = 5;
|
||||||
|
|
||||||
|
auto streams = buildStreams({b, def, c, d}, {}, palette(), {}, kDefaultId);
|
||||||
|
|
||||||
|
ASSERT_EQ(streams.size(), 6u);
|
||||||
|
EXPECT_EQ(streams[0].name, "B");
|
||||||
|
EXPECT_EQ(streams[1].name, "Default");
|
||||||
|
EXPECT_EQ(streams[2].name, "C");
|
||||||
|
EXPECT_EQ(streams[3].name, "Default");
|
||||||
|
EXPECT_EQ(streams[4].name, "D");
|
||||||
|
EXPECT_EQ(streams[5].name, "Default");
|
||||||
|
|
||||||
|
// Every default copy is independent playback state (fresh counters), so a
|
||||||
|
// copy finishing its passes advances to the next stream rather than
|
||||||
|
// restarting "the default" forever.
|
||||||
|
for (size_t i = 1; i < streams.size(); i += 2) {
|
||||||
|
EXPECT_EQ(streams[i].name, "Default");
|
||||||
|
EXPECT_EQ(streams[i].completed, 0) << "default copy " << i;
|
||||||
|
EXPECT_EQ(streams[i].wordsPlaced, 0) << "default copy " << i;
|
||||||
|
EXPECT_EQ(streams[i].repeat, kDefaultRepeatCount) << "default copy " << i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TEST(ScrollerStreamsTest, DefaultOnlyWhenNothingSelected)
|
TEST(ScrollerStreamsTest, DefaultOnlyWhenNothingSelected)
|
||||||
{
|
{
|
||||||
ProclamationRecord p1;
|
ProclamationRecord p1;
|
||||||
|
|||||||
Reference in New Issue
Block a user