5 Commits
Author SHA1 Message Date
NeilClack 2917d33866 fixed (bug) Audio not looping after ending.
build-dev / nix-build (push) Successful in 46s
2026-08-28 13:55:20 -05:00
NeilClack 3f6201182e Publish live-update manifest to fileserver (dev)
build-dev / nix-build (push) Successful in 43s
After pushing the dev closure to Attic, write a small JSON manifest
(version + storePath) to the fileserver for kiosks to poll. storePath is
the readlink of the result-pkg out-link, so it matches exactly the
closure pushed to the dev Attic cache and can be fetched with
'nix copy --from' without building from source. Upload goes to
/mnt/infrastructure_data/files/radiantwave-dev/manifest.json via the
build-server's radiantwave-infra SSH alias.
2026-08-27 20:58:13 -05:00
NeilClack d84307bd0e fixed (bug) Audio output device not switching
build-dev / nix-build (push) Successful in 47s
2026-08-27 18:45:57 -05:00
NeilClack 43cd93bbd7 Switch from window-size to render-output-size 2026-08-25 11:30:20 -05:00
NeilClack 92ba9f39fb Switch from window-size to render-output-size 2026-08-25 11:28:02 -05:00
9 changed files with 1101 additions and 750 deletions
+14
View File
@@ -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
View File
@@ -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; playTrackLooping(i);
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;
}
} }
} }
} }
+32
View File
@@ -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;
+16 -3
View File
@@ -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) {
+4
View File
@@ -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
+7
View File
@@ -196,6 +196,13 @@ 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 " +
+179 -275
View File
@@ -2,36 +2,34 @@
#include "app_state.h" #include "app_state.h"
#include "audio/audio_downloader.h" #include "audio/audio_downloader.h"
#include "logical_size.h"
#include "proclamation_options_page.h" #include "proclamation_options_page.h"
#include "scramble/fibonacci_scrambler.h" #include "scramble/fibonacci_scrambler.h"
#include "utf8_utils.h" #include "utf8_utils.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h> #include <SDL3_ttf/SDL_ttf.h>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstdint>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
#include <cstdint>
#include <filesystem> #include <filesystem>
#include <vector> #include <vector>
using radiantwave::FibonacciLinePattern; using radiantwave::FibonacciLinePattern;
using radiantwave::scroller::ProclamationStream; using radiantwave::scroller::ProclamationStream;
namespace namespace {
{
constexpr float kFadeSeconds = 5.0f; constexpr float kFadeSeconds = 5.0f;
constexpr float kVelocityMin = -3500.0f; constexpr float kVelocityMin = -3500.0f;
constexpr float kVelocityMax = 3500.0f; constexpr float kVelocityMax = 3500.0f;
} // anonymous namespace } // anonymous namespace
void ScrollerPage::onPush(AppState &state) void ScrollerPage::onPush(AppState &state) {
{
state_ = &state; state_ = &state;
// Save the current logical presentation (LETTERBOX at design resolution) // Save the current logical presentation (LETTERBOX at design resolution)
@@ -42,19 +40,20 @@ void ScrollerPage::onPush(AppState &state)
SDL_GetRenderLogicalPresentation(state.renderer, &savedLogicalW_, SDL_GetRenderLogicalPresentation(state.renderer, &savedLogicalW_,
&savedLogicalH_, &savedMode_); &savedLogicalH_, &savedMode_);
SDL_Window *window = SDL_GetRenderWindow(state.renderer); // Use the renderer's actual output size in pixels, not the window size in
// points. SDL_GetWindowSize() returns screen coordinates, which can be
// smaller than the real drawable if the display has a content scale
// applied (common on some monitors/compositors) — that mismatch caused
// this page to lay out and center within a sub-region of the screen
// instead of the full physical canvas. SDL_GetRenderOutputSize() gives us
// the true pixel dimensions of whatever we're actually rendering into.
int windowW = 0; int windowW = 0;
int windowH = 0; int windowH = 0;
if (window) SDL_GetRenderOutputSize(state.renderer, &windowW, &windowH);
{ if (windowW <= 0) {
SDL_GetWindowSize(window, &windowW, &windowH);
}
if (windowW <= 0)
{
windowW = radiantwave::kDesignWidth; windowW = radiantwave::kDesignWidth;
} }
if (windowH <= 0) if (windowH <= 0) {
{
windowH = radiantwave::kDesignHeight; windowH = radiantwave::kDesignHeight;
} }
@@ -82,49 +81,44 @@ void ScrollerPage::onPush(AppState &state)
// plays. A frequency file that is not on disk yet falls back to the manual // plays. A frequency file that is not on disk yet falls back to the manual
// track rather than playing nothing. // track rather than playing nothing.
std::string track = state.config.get().audio.current_track; std::string track = state.config.get().audio.current_track;
if (state.config.get().audio.dynamic_audio && !streams_.empty()) if (state.config.get().audio.dynamic_audio && !streams_.empty()) {
{
const ProclamationStream &stream = streams_[streamIndex_]; const ProclamationStream &stream = streams_[streamIndex_];
if (stream.solfeggio_frequency_id != 0 && !stream.audioFilename.empty() && if (stream.solfeggio_frequency_id != 0 &&
std::filesystem::exists(audioFilePath(stream.audioFilename))) !stream.audioFilename.empty() &&
{ std::filesystem::exists(audioFilePath(stream.audioFilename))) {
track = stream.audioFilename; track = stream.audioFilename;
} }
} }
if (std::filesystem::exists(audioFilePath(track))) if (std::filesystem::exists(audioFilePath(track))) {
{
state.audio.playTrackFadeIn(track, kFadeSeconds); state.audio.playTrackFadeIn(track, kFadeSeconds);
} }
state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() {
{
if (state_) { if (state_) {
state_->pages.pop(*state_); state_->pages.pop(*state_);
} }); }
});
} }
void ScrollerPage::onResume(AppState &state) void ScrollerPage::onResume(AppState &state) {
{
state_ = &state; state_ = &state;
createFont(state); createFont(state);
createPatterns(); createPatterns();
rebuildWordTextures(); rebuildWordTextures();
state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() state.keybinds.bind(SDLK_ESCAPE, "scroller_exit", [this]() {
{
if (state_) { if (state_) {
state_->pages.pop(*state_); state_->pages.pop(*state_);
} }); }
});
} }
void ScrollerPage::onPause(AppState &state) void ScrollerPage::onPause(AppState &state) {
{
state.keybinds.unbindAll(); state.keybinds.unbindAll();
destroyResources(); destroyResources();
} }
void ScrollerPage::onPop(AppState &state) void ScrollerPage::onPop(AppState &state) {
{
// Restore the original logical presentation so other pages (Home, // Restore the original logical presentation so other pages (Home,
// Settings, etc.) render in the design-resolution LETTERBOX space. // Settings, etc.) render in the design-resolution LETTERBOX space.
SDL_SetRenderLogicalPresentation(state.renderer, savedLogicalW_, SDL_SetRenderLogicalPresentation(state.renderer, savedLogicalW_,
@@ -136,14 +130,10 @@ void ScrollerPage::onPop(AppState &state)
TTF_Quit(); TTF_Quit();
} }
void ScrollerPage::handleEvent(const SDL_Event &) void ScrollerPage::handleEvent(const SDL_Event &) {}
{
}
void ScrollerPage::update(float dt) void ScrollerPage::update(float dt) {
{ if (state_) {
if (state_)
{
state_->audio.update(dt); state_->audio.update(dt);
} }
@@ -151,69 +141,55 @@ void ScrollerPage::update(float dt)
scrollOffset_ += velocity_ * dt; scrollOffset_ += velocity_ * dt;
float move = std::floor(scrollOffset_); float move = std::floor(scrollOffset_);
if (move != 0.0f) if (move != 0.0f) {
{
scrollOffset_ -= move; scrollOffset_ -= move;
for (auto &line : lines_) for (auto &line : lines_) {
{ for (auto &w : line.words) {
for (auto &w : line.words)
{
w.y += move; w.y += move;
} }
} }
} }
if (lines_.empty()) if (lines_.empty()) {
{
return; return;
} }
if (velocity_ > 0.0f) if (velocity_ > 0.0f) {
{
// Lines move downward. Recycle every line that scrolled fully off the // Lines move downward. Recycle every line that scrolled fully off the
// bottom this frame (movement is continuous, so one frame can cover // bottom this frame (movement is continuous, so one frame can cover
// several line heights) and add a replacement just above the top. // several line heights) and add a replacement just above the top.
while (!lines_.empty() && lines_.back().words[0].y > screenH_) while (!lines_.empty() && lines_.back().words[0].y > screenH_) {
{
float baseY = lines_.front().words[0].y - lineHeight_; float baseY = lines_.front().words[0].y - lineHeight_;
lines_.pop_back(); lines_.pop_back();
if (!addLineAt(baseY)) if (!addLineAt(baseY)) {
{
break; break;
} }
} }
} } else if (velocity_ < 0.0f) {
else if (velocity_ < 0.0f)
{
// Lines move upward. Recycle every line that scrolled fully off the top // Lines move upward. Recycle every line that scrolled fully off the top
// this frame and add a replacement just below the bottom. // this frame and add a replacement just below the bottom.
while (!lines_.empty() && while (!lines_.empty() &&
lines_.front().words[0].y + lines_.front().words[0].height < 0.0f) lines_.front().words[0].y + lines_.front().words[0].height <
{ 0.0f) {
float baseY = lines_.back().words[0].y + lineHeight_; float baseY = lines_.back().words[0].y + lineHeight_;
lines_.pop_front(); lines_.pop_front();
if (!addLineAt(baseY)) if (!addLineAt(baseY)) {
{
break; break;
} }
} }
} }
} }
void ScrollerPage::render(SDL_Renderer *renderer) void ScrollerPage::render(SDL_Renderer *renderer) {
{
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
SDL_RenderClear(renderer); SDL_RenderClear(renderer);
for (const auto &line : lines_) for (const auto &line : lines_) {
{ if (line.words.empty()) {
if (line.words.empty())
{
continue; continue;
} }
float startX = std::floor((screenW_ - line.width) / 2.0f); float startX = std::floor((screenW_ - line.width) / 2.0f);
for (const auto &w : line.words) for (const auto &w : line.words) {
{
SDL_SetTextureColorMod(w.texture, line.r, line.g, line.b); SDL_SetTextureColorMod(w.texture, line.r, line.g, line.b);
SDL_FRect dst{startX + w.x, w.y, w.width, w.height}; SDL_FRect dst{startX + w.x, w.y, w.width, w.height};
SDL_RenderTexture(renderer, w.texture, nullptr, &dst); SDL_RenderTexture(renderer, w.texture, nullptr, &dst);
@@ -221,53 +197,46 @@ void ScrollerPage::render(SDL_Renderer *renderer)
} }
} }
void ScrollerPage::buildStreams(AppState &state) void ScrollerPage::buildStreams(AppState &state) {
{
streams_.clear(); streams_.clear();
fallbackPalette_.clear(); fallbackPalette_.clear();
lines_.clear(); lines_.clear();
streamIndex_ = 0; streamIndex_ = 0;
auto proclamations = state.db.getActiveProclamations(); auto proclamations = state.db.getActiveProclamations();
if (proclamations.empty()) if (proclamations.empty()) {
{
return; return;
} }
for (const auto &c : state.db.getPaletteForFrequencyId(5)) for (const auto &c :
{ // 528 Hz palette state.db.getPaletteForFrequencyId(5)) { // 528 Hz palette
fallbackPalette_.push_back({c.r, c.g, c.b, c.a}); fallbackPalette_.push_back({c.r, c.g, c.b, c.a});
} }
std::unordered_map<int, std::vector<radiantwave::scroller::StreamColor>> palettesByFreqId; std::unordered_map<int, std::vector<radiantwave::scroller::StreamColor>>
for (const auto &rec : proclamations) palettesByFreqId;
{ for (const auto &rec : proclamations) {
int freqId = rec.solfeggio_frequency_id; int freqId = rec.solfeggio_frequency_id;
if (palettesByFreqId.count(freqId) != 0) if (palettesByFreqId.count(freqId) != 0) {
{
continue; continue;
} }
std::vector<radiantwave::scroller::StreamColor> colors; std::vector<radiantwave::scroller::StreamColor> colors;
for (const auto &c : state.db.getPaletteForFrequencyId(freqId)) for (const auto &c : state.db.getPaletteForFrequencyId(freqId)) {
{
colors.push_back({c.r, c.g, c.b, c.a}); colors.push_back({c.r, c.g, c.b, c.a});
} }
palettesByFreqId[freqId] = std::move(colors); palettesByFreqId[freqId] = std::move(colors);
} }
std::unordered_map<int, radiantwave::db::AudioFileRecord> audioByFreqId; std::unordered_map<int, radiantwave::db::AudioFileRecord> audioByFreqId;
for (const auto &af : state.db.getAudioFiles()) for (const auto &af : state.db.getAudioFiles()) {
{
int freqId = af.solfeggio_frequency_id; int freqId = af.solfeggio_frequency_id;
// Self-heal for databases seeded from a legacy manifest that left the // Self-heal for databases seeded from a legacy manifest that left the
// frequency id at 0 for every row: recover the id from the filename. // frequency id at 0 for every row: recover the id from the filename.
if (freqId == 0) if (freqId == 0) {
{
freqId = radiantwave::audio::frequencyIdForHz( freqId = radiantwave::audio::frequencyIdForHz(
radiantwave::audio::hzFromFilename(af.filename)); radiantwave::audio::hzFromFilename(af.filename));
} }
if (freqId != 0) if (freqId != 0) {
{
audioByFreqId[freqId] = af; audioByFreqId[freqId] = af;
} }
} }
@@ -277,10 +246,8 @@ void ScrollerPage::buildStreams(AppState &state)
DEFAULT_PROPLEAMATION_ID); DEFAULT_PROPLEAMATION_ID);
} }
void ScrollerPage::createFont(AppState &state) void ScrollerPage::createFont(AppState &state) {
{ if (font_) {
if (font_)
{
TTF_CloseFont(font_); TTF_CloseFont(font_);
font_ = nullptr; font_ = nullptr;
} }
@@ -291,46 +258,41 @@ void ScrollerPage::createFont(AppState &state)
clearCharTextures(); clearCharTextures();
fontPxSize_ = state.config.get().proclamation.font_size; fontPxSize_ = state.config.get().proclamation.font_size;
if (fontPxSize_ <= 0) if (fontPxSize_ <= 0) {
{
fontPxSize_ = 32; fontPxSize_ = 32;
} }
font_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", fontPxSize_); font_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", fontPxSize_);
if (!font_) if (!font_) {
{
return; return;
} }
int w = 0; int w = 0;
int h = 0; int h = 0;
TTF_GetStringSize(font_, "Mg", 0, &w, &h); TTF_GetStringSize(font_, "Mg", 0, &w, &h);
lineHeight_ = h > 0 ? static_cast<float>(h) : static_cast<float>(fontPxSize_); lineHeight_ =
h > 0 ? static_cast<float>(h) : static_cast<float>(fontPxSize_);
spaceWidth_ = measureWidth(" "); spaceWidth_ = measureWidth(" ");
if (spaceWidth_ <= 0.0f) if (spaceWidth_ <= 0.0f) {
{
spaceWidth_ = static_cast<float>(fontPxSize_) * 0.5f; spaceWidth_ = static_cast<float>(fontPxSize_) * 0.5f;
} }
} }
void ScrollerPage::createPatterns() void ScrollerPage::createPatterns() {
{
patterns_.clear(); patterns_.clear();
patterns_.reserve(streams_.size()); patterns_.reserve(streams_.size());
for (size_t i = 0; i < streams_.size(); ++i) for (size_t i = 0; i < streams_.size(); ++i) {
{
int peak = computePeakWordCount(streams_[i].words); int peak = computePeakWordCount(streams_[i].words);
patterns_.emplace_back(peak, maxLineWidth_, spaceWidth_, patterns_.emplace_back(
[this](const std::string &word) peak, maxLineWidth_, spaceWidth_,
{ return measureWidth(word); }); [this](const std::string &word) { return measureWidth(word); });
} }
} }
int ScrollerPage::computePeakWordCount(const std::vector<std::string> &words) const int ScrollerPage::computePeakWordCount(
{ const std::vector<std::string> &words) const {
if (words.empty() || screenW_ <= 0.0f) if (words.empty() || screenW_ <= 0.0f) {
{
return 1; return 1;
} }
@@ -338,14 +300,12 @@ int ScrollerPage::computePeakWordCount(const std::vector<std::string> &words) co
// spacing. The peak is the number of whole words that fit across the // spacing. The peak is the number of whole words that fit across the
// screen, so the widest Fibonacci lines fill the width at natural spacing. // screen, so the widest Fibonacci lines fill the width at natural spacing.
double total = 0.0; double total = 0.0;
for (const auto &w : words) for (const auto &w : words) {
{
total += measureWidth(w); total += measureWidth(w);
} }
double n = static_cast<double>(words.size()); double n = static_cast<double>(words.size());
double perWord = (total + (n - 1.0) * spaceWidth_) / n; double perWord = (total + (n - 1.0) * spaceWidth_) / n;
if (perWord <= 0.0) if (perWord <= 0.0) {
{
perWord = 1.0; perWord = 1.0;
} }
@@ -353,45 +313,36 @@ int ScrollerPage::computePeakWordCount(const std::vector<std::string> &words) co
return fit < 1 ? 1 : fit; return fit < 1 ? 1 : fit;
} }
void ScrollerPage::destroyResources() void ScrollerPage::destroyResources() {
{
clearCharTextures(); clearCharTextures();
for (auto &entry : wordTextures_) for (auto &entry : wordTextures_) {
{ if (entry.second.texture) {
if (entry.second.texture)
{
SDL_DestroyTexture(entry.second.texture); SDL_DestroyTexture(entry.second.texture);
} }
} }
wordTextures_.clear(); wordTextures_.clear();
if (font_) if (font_) {
{
TTF_CloseFont(font_); TTF_CloseFont(font_);
font_ = nullptr; font_ = nullptr;
} }
} }
void ScrollerPage::rebuildWordTextures() void ScrollerPage::rebuildWordTextures() {
{
wordTextures_.clear(); wordTextures_.clear();
for (auto &line : lines_) for (auto &line : lines_) {
{
float width = 0.0f; float width = 0.0f;
for (auto &item : line.words) for (auto &item : line.words) {
{
ensureWordTexture(item.text); ensureWordTexture(item.text);
const CachedWord &cached = wordTextures_[item.text]; const CachedWord &cached = wordTextures_[item.text];
if (!cached.texture) if (!cached.texture) {
{
continue; continue;
} }
item.texture = cached.texture; item.texture = cached.texture;
item.width = cached.width; item.width = cached.width;
item.height = cached.height; item.height = cached.height;
if (width > 0.0f) if (width > 0.0f) {
{
width += spaceWidth_; width += spaceWidth_;
} }
item.x = width; item.x = width;
@@ -401,31 +352,25 @@ void ScrollerPage::rebuildWordTextures()
} }
} }
void ScrollerPage::prefillLines() void ScrollerPage::prefillLines() {
{ if (streams_.empty() || lineHeight_ <= 0.0f) {
if (streams_.empty() || lineHeight_ <= 0.0f)
{
return; return;
} }
int n = static_cast<int>(std::ceil(screenH_ / lineHeight_)) + 2; int n = static_cast<int>(std::ceil(screenH_ / lineHeight_)) + 2;
for (int i = 0; i < n; ++i) for (int i = 0; i < n; ++i) {
{
float baseY = -lineHeight_ + static_cast<float>(i) * lineHeight_; float baseY = -lineHeight_ + static_cast<float>(i) * lineHeight_;
addLineAt(baseY); addLineAt(baseY);
} }
} }
bool ScrollerPage::addLineAt(float baseY) bool ScrollerPage::addLineAt(float baseY) {
{ if (streams_.empty() || !font_) {
if (streams_.empty() || !font_)
{
return false; return false;
} }
ProclamationStream &stream = streams_[streamIndex_]; ProclamationStream &stream = streams_[streamIndex_];
if (stream.words.empty()) if (stream.words.empty()) {
{
advanceStream(); advanceStream();
return false; return false;
} }
@@ -433,7 +378,8 @@ bool ScrollerPage::addLineAt(float baseY)
FibonacciLinePattern &pattern = patterns_[streamIndex_]; FibonacciLinePattern &pattern = patterns_[streamIndex_];
// Capture palette state before any stream advance. // Capture palette state before any stream advance.
std::vector<radiantwave::scroller::StreamColor> paletteCopy = stream.palette; std::vector<radiantwave::scroller::StreamColor> paletteCopy =
stream.palette;
int lineColorIndex = stream.colorIndex; int lineColorIndex = stream.colorIndex;
stream.colorIndex++; stream.colorIndex++;
@@ -443,17 +389,15 @@ bool ScrollerPage::addLineAt(float baseY)
stream.wordsPlaced += placed; stream.wordsPlaced += placed;
// First word of a fresh pass is the "start" of the proclamation; print to // First word of a fresh pass is the "start" of the proclamation; print to
// stdout once so the console shows the current title while it plays. // stdout once so the console shows the current title while it plays.
if (stream.wordsPlaced == placed) if (stream.wordsPlaced == placed) {
{
std::printf("Playing %s\n", stream.name.c_str()); std::printf("Playing %s\n", stream.name.c_str());
} }
bool advanced = false; bool advanced = false;
while (!advanced && !stream.words.empty() && stream.wordsPlaced >= static_cast<int>(stream.words.size())) while (!advanced && !stream.words.empty() &&
{ stream.wordsPlaced >= static_cast<int>(stream.words.size())) {
stream.wordsPlaced -= static_cast<int>(stream.words.size()); stream.wordsPlaced -= static_cast<int>(stream.words.size());
stream.completed++; stream.completed++;
if (stream.completed >= stream.repeat) if (stream.completed >= stream.repeat) {
{
stream.completed = 0; stream.completed = 0;
advanceStream(); advanceStream();
advanced = true; advanced = true;
@@ -462,12 +406,10 @@ bool ScrollerPage::addLineAt(float baseY)
ScrollerLine line; ScrollerLine line;
float width = 0.0f; float width = 0.0f;
for (const auto &text : wordStrs) for (const auto &text : wordStrs) {
{
ensureWordTexture(text); ensureWordTexture(text);
const CachedWord &cached = wordTextures_[text]; const CachedWord &cached = wordTextures_[text];
if (!cached.texture) if (!cached.texture) {
{
continue; continue;
} }
WordItem item; WordItem item;
@@ -476,8 +418,7 @@ bool ScrollerPage::addLineAt(float baseY)
item.width = cached.width; item.width = cached.width;
item.height = cached.height; item.height = cached.height;
item.y = baseY; item.y = baseY;
if (width > 0.0f) if (width > 0.0f) {
{
width += spaceWidth_; width += spaceWidth_;
} }
item.x = width; item.x = width;
@@ -485,77 +426,63 @@ bool ScrollerPage::addLineAt(float baseY)
line.words.push_back(item); line.words.push_back(item);
} }
if (line.words.empty()) if (line.words.empty()) {
{
return false; return false;
} }
line.width = width; line.width = width;
if (!paletteCopy.empty()) if (!paletteCopy.empty()) {
{ const auto &c =
const auto &c = paletteCopy[lineColorIndex % static_cast<int>(paletteCopy.size())]; paletteCopy[lineColorIndex % static_cast<int>(paletteCopy.size())];
line.r = c.r; line.r = c.r;
line.g = c.g; line.g = c.g;
line.b = c.b; line.b = c.b;
} }
if (!lines_.empty() && baseY < lines_.front().words[0].y) if (!lines_.empty() && baseY < lines_.front().words[0].y) {
{
lines_.push_front(std::move(line)); lines_.push_front(std::move(line));
} } else {
else
{
lines_.push_back(std::move(line)); lines_.push_back(std::move(line));
} }
return true; return true;
} }
void ScrollerPage::advanceStream() void ScrollerPage::advanceStream() {
{ if (streams_.empty()) {
if (streams_.empty())
{
return; return;
} }
streamIndex_ = (streamIndex_ + 1) % static_cast<int>(streams_.size()); streamIndex_ = (streamIndex_ + 1) % static_cast<int>(streams_.size());
// Diagnostic: report the palette the incoming stream will render so the // Diagnostic: report the palette the incoming stream will render so the
// console shows whether colors should change when this stream starts. // console shows whether colors should change when this stream starts.
const ProclamationStream &stream = streams_[streamIndex_]; const ProclamationStream &stream = streams_[streamIndex_];
if (!stream.palette.empty()) if (!stream.palette.empty()) {
{
const auto &c = stream.palette[0]; const auto &c = stream.palette[0];
std::printf("[Scroller] -> %s freqId=%d palette[0]=#%02X%02X%02X\n", std::printf("[Scroller] -> %s freqId=%d palette[0]=#%02X%02X%02X\n",
stream.name.c_str(), stream.solfeggio_frequency_id, stream.name.c_str(), stream.solfeggio_frequency_id, c.r,
c.r, c.g, c.b); c.g, c.b);
} } else {
else
{
std::printf("[Scroller] -> %s freqId=%d palette=EMPTY\n", std::printf("[Scroller] -> %s freqId=%d palette=EMPTY\n",
stream.name.c_str(), stream.solfeggio_frequency_id); stream.name.c_str(), stream.solfeggio_frequency_id);
} }
switchAudioForCurrentStream(); switchAudioForCurrentStream();
} }
void ScrollerPage::switchAudioForCurrentStream() void ScrollerPage::switchAudioForCurrentStream() {
{ if (prefilling_ || streams_.empty() || !state_) {
if (prefilling_ || streams_.empty() || !state_)
{
return; return;
} }
// Dynamic audio only: with a fixed manual track selected the audio must // Dynamic audio only: with a fixed manual track selected the audio must
// never switch as proclamations change. // never switch as proclamations change.
if (!state_->config.get().audio.dynamic_audio) if (!state_->config.get().audio.dynamic_audio) {
{
return; return;
} }
const ProclamationStream &stream = streams_[streamIndex_]; const ProclamationStream &stream = streams_[streamIndex_];
if (stream.solfeggio_frequency_id == 0 || stream.audioFilename.empty()) if (stream.solfeggio_frequency_id == 0 || stream.audioFilename.empty()) {
{
return; // default stream: never touch audio return; // default stream: never touch audio
} }
if (!std::filesystem::exists(audioFilePath(stream.audioFilename))) if (!std::filesystem::exists(audioFilePath(stream.audioFilename))) {
{
return; // keep current track; retried on the next activation return; // keep current track; retried on the next activation
} }
// Crossfades over kFadeSeconds; a no-op when the requested track is the // Crossfades over kFadeSeconds; a no-op when the requested track is the
@@ -563,14 +490,11 @@ void ScrollerPage::switchAudioForCurrentStream()
state_->audio.playTrackCrossfade(stream.audioFilename, kFadeSeconds); state_->audio.playTrackCrossfade(stream.audioFilename, kFadeSeconds);
} }
void ScrollerPage::updateAutoVelocity(float dt) void ScrollerPage::updateAutoVelocity(float dt) {
{ switch (velocityState_) {
switch (velocityState_)
{
case 0: // ramping to the negative extreme case 0: // ramping to the negative extreme
velocity_ -= velocityStep_ * dt; velocity_ -= velocityStep_ * dt;
if (velocity_ <= kVelocityMin) if (velocity_ <= kVelocityMin) {
{
velocity_ = kVelocityMin; velocity_ = kVelocityMin;
velocityTimer_ = velocityDelay_; velocityTimer_ = velocityDelay_;
velocityState_ = 1; velocityState_ = 1;
@@ -578,15 +502,13 @@ void ScrollerPage::updateAutoVelocity(float dt)
break; break;
case 1: // holding at the negative extreme case 1: // holding at the negative extreme
velocityTimer_ -= dt; velocityTimer_ -= dt;
if (velocityTimer_ <= 0.0f) if (velocityTimer_ <= 0.0f) {
{
velocityState_ = 2; velocityState_ = 2;
} }
break; break;
case 2: // ramping to the positive extreme case 2: // ramping to the positive extreme
velocity_ += velocityStep_ * dt; velocity_ += velocityStep_ * dt;
if (velocity_ >= kVelocityMax) if (velocity_ >= kVelocityMax) {
{
velocity_ = kVelocityMax; velocity_ = kVelocityMax;
velocityTimer_ = velocityDelay_; velocityTimer_ = velocityDelay_;
velocityState_ = 3; velocityState_ = 3;
@@ -594,8 +516,7 @@ void ScrollerPage::updateAutoVelocity(float dt)
break; break;
case 3: // holding at the positive extreme case 3: // holding at the positive extreme
velocityTimer_ -= dt; velocityTimer_ -= dt;
if (velocityTimer_ <= 0.0f) if (velocityTimer_ <= 0.0f) {
{
velocityState_ = 0; velocityState_ = 0;
} }
break; break;
@@ -605,10 +526,8 @@ void ScrollerPage::updateAutoVelocity(float dt)
} }
} }
void ScrollerPage::ensureWordTexture(const std::string &word) void ScrollerPage::ensureWordTexture(const std::string &word) {
{ if (wordTextures_.count(word) != 0) {
if (wordTextures_.count(word) != 0)
{
return; return;
} }
@@ -631,69 +550,64 @@ void ScrollerPage::ensureWordTexture(const std::string &word)
// breaking, centering, and the Fibonacci line pattern are unchanged; only // breaking, centering, and the Fibonacci line pattern are unchanged; only
// the glyph pixels move. // the glyph pixels move.
CachedWord cached; CachedWord cached;
if (font_ && state_) if (font_ && state_) {
{
std::vector<uint32_t> codePoints; std::vector<uint32_t> codePoints;
if (radiantwave::splitUtf8(word, codePoints) && !codePoints.empty()) if (radiantwave::splitUtf8(word, codePoints) && !codePoints.empty()) {
{
std::vector<CharEntry *> glyphs; std::vector<CharEntry *> glyphs;
float totalWidth = 0.0f; float totalWidth = 0.0f;
float glyphHeight = 0.0f; float glyphHeight = 0.0f;
for (uint32_t cp : codePoints) for (uint32_t cp : codePoints) {
{
const std::string glyphText = radiantwave::encodeUtf8(cp); const std::string glyphText = radiantwave::encodeUtf8(cp);
if (glyphText.empty()) if (glyphText.empty()) {
{
continue; continue;
} }
ensureCharTexture(glyphText); ensureCharTexture(glyphText);
CharEntry &entry = charTextures_[glyphText]; CharEntry &entry = charTextures_[glyphText];
if (!entry.surface) if (!entry.surface) {
{
continue; continue;
} }
glyphs.push_back(&entry); glyphs.push_back(&entry);
totalWidth += entry.advance; totalWidth += entry.advance;
if (entry.glyphH > glyphHeight) if (entry.glyphH > glyphHeight) {
{
glyphHeight = entry.glyphH; glyphHeight = entry.glyphH;
} }
} }
if (!glyphs.empty()) if (!glyphs.empty()) {
{ const int wordW =
const int wordW = std::max(1, static_cast<int>(std::lround(totalWidth))); std::max(1, static_cast<int>(std::lround(totalWidth)));
const int wordH = std::max(1, static_cast<int>(std::lround(glyphHeight))); const int wordH =
SDL_Surface *wordSurface = SDL_CreateSurface(wordW, wordH, SDL_PIXELFORMAT_RGBA8888); std::max(1, static_cast<int>(std::lround(glyphHeight)));
if (wordSurface) SDL_Surface *wordSurface =
{ SDL_CreateSurface(wordW, wordH, SDL_PIXELFORMAT_RGBA8888);
if (wordSurface) {
// Every glyph surface carries the font's full line height // Every glyph surface carries the font's full line height
// (probe-verified), so y = 0 keeps every crop on the shared // (probe-verified), so y = 0 keeps every crop on the shared
// baseline and per-word textures stay line-height tall. // baseline and per-word textures stay line-height tall.
float x = 0.0f; float x = 0.0f;
for (const CharEntry *entry : glyphs) for (const CharEntry *entry : glyphs) {
{
SDL_Rect srcRect{0, 0, static_cast<int>(entry->glyphW), SDL_Rect srcRect{0, 0, static_cast<int>(entry->glyphW),
static_cast<int>(entry->glyphH)}; static_cast<int>(entry->glyphH)};
SDL_Rect dstRect{static_cast<int>(std::lround(x)), 0, SDL_Rect dstRect{static_cast<int>(std::lround(x)), 0,
static_cast<int>(entry->glyphW), static_cast<int>(entry->glyphW),
static_cast<int>(entry->glyphH)}; static_cast<int>(entry->glyphH)};
SDL_BlitSurface(entry->surface, &srcRect, wordSurface, &dstRect); SDL_BlitSurface(entry->surface, &srcRect, wordSurface,
&dstRect);
x += entry->advance; x += entry->advance;
} }
cached.texture = SDL_CreateTextureFromSurface(state_->renderer, wordSurface); cached.texture = SDL_CreateTextureFromSurface(
state_->renderer, wordSurface);
// The scroller steps glyphs in whole pixels, so bilinear // The scroller steps glyphs in whole pixels, so bilinear
// filtering can only smear them: nearest-neighbor keeps // filtering can only smear them: nearest-neighbor keeps
// every frame pixel-sharp. // every frame pixel-sharp.
if (cached.texture) if (cached.texture) {
{ SDL_SetTextureScaleMode(cached.texture,
SDL_SetTextureScaleMode(cached.texture, SDL_SCALEMODE_NEAREST); SDL_SCALEMODE_NEAREST);
} }
cached.width = static_cast<float>(wordSurface->w); cached.width = static_cast<float>(wordSurface->w);
cached.height = static_cast<float>(wordSurface->h); cached.height = static_cast<float>(wordSurface->h);
SDL_DestroySurface(wordSurface); SDL_DestroySurface(wordSurface);
if (!cached.texture) if (!cached.texture) {
{
cached.width = 0.0f; cached.width = 0.0f;
cached.height = 0.0f; cached.height = 0.0f;
} }
@@ -704,10 +618,8 @@ void ScrollerPage::ensureWordTexture(const std::string &word)
wordTextures_[word] = cached; wordTextures_[word] = cached;
} }
void ScrollerPage::ensureCharTexture(const std::string &glyph) void ScrollerPage::ensureCharTexture(const std::string &glyph) {
{ if (charTextures_.count(glyph) != 0) {
if (charTextures_.count(glyph) != 0)
{
return; return;
} }
@@ -716,25 +628,23 @@ void ScrollerPage::ensureCharTexture(const std::string &glyph)
// font size; createFont() clears the atlas whenever the font or its size // font size; createFont() clears the atlas whenever the font or its size
// changes, and pause/pop clear it as part of destroyResources(). // changes, and pause/pop clear it as part of destroyResources().
CharEntry entry; CharEntry entry;
if (font_ && state_) if (font_ && state_) {
{
SDL_Surface *surface = TTF_RenderText_Blended(font_, glyph.c_str(), 0, SDL_Surface *surface = TTF_RenderText_Blended(font_, glyph.c_str(), 0,
{255, 255, 255, 255}); {255, 255, 255, 255});
if (surface) if (surface) {
{
const int gW = surface->w; const int gW = surface->w;
const int gH = surface->h; const int gH = surface->h;
const int n = radiantwave::scramble::canvasSize(gW, gH); const int n = radiantwave::scramble::canvasSize(gW, gH);
if (n > 0) if (n > 0) {
{
// The scrambler needs tightly packed RGBA8888; a TTF surface's // The scrambler needs tightly packed RGBA8888; a TTF surface's
// pitch can exceed gW * 4, so copy row by row. // pitch can exceed gW * 4, so copy row by row.
std::vector<uint8_t> source(static_cast<size_t>(gW) * gH * 4); std::vector<uint8_t> source(static_cast<size_t>(gW) * gH * 4);
const uint8_t *pixels = static_cast<const uint8_t *>(surface->pixels); const uint8_t *pixels =
for (int y = 0; y < gH; ++y) static_cast<const uint8_t *>(surface->pixels);
{ for (int y = 0; y < gH; ++y) {
std::memcpy(source.data() + static_cast<size_t>(y) * gW * 4, std::memcpy(source.data() + static_cast<size_t>(y) * gW * 4,
pixels + static_cast<size_t>(y) * surface->pitch, pixels +
static_cast<size_t>(y) * surface->pitch,
static_cast<size_t>(gW) * 4); static_cast<size_t>(gW) * 4);
} }
@@ -743,25 +653,24 @@ void ScrollerPage::ensureCharTexture(const std::string &glyph)
// The fixed seed makes every device render the same glyph as // The fixed seed makes every device render the same glyph as
// the same scrambled pattern, with no key to persist or ship. // the same scrambled pattern, with no key to persist or ship.
radiantwave::scramble::ScrambleConfig config; radiantwave::scramble::ScrambleConfig config;
if (radiantwave::scramble::scramble(source.data(), scrambled.data(), if (radiantwave::scramble::scramble(
gW, gH, config)) source.data(), scrambled.data(), gW, gH, config)) {
{ SDL_Surface *tile =
SDL_Surface *tile = SDL_CreateSurface(n, n, SDL_PIXELFORMAT_RGBA8888); SDL_CreateSurface(n, n, SDL_PIXELFORMAT_RGBA8888);
if (tile) if (tile) {
{
uint8_t *dst = static_cast<uint8_t *>(tile->pixels); uint8_t *dst = static_cast<uint8_t *>(tile->pixels);
for (int y = 0; y < n; ++y) for (int y = 0; y < n; ++y) {
{ std::memcpy(dst + static_cast<size_t>(y) *
std::memcpy(dst + static_cast<size_t>(y) * tile->pitch, tile->pitch,
scrambled.data() + static_cast<size_t>(y) * n * 4, scrambled.data() +
static_cast<size_t>(y) * n * 4,
static_cast<size_t>(n) * 4); static_cast<size_t>(n) * 4);
} }
entry.surface = tile; entry.surface = tile;
entry.glyphW = static_cast<float>(gW); entry.glyphW = static_cast<float>(gW);
entry.glyphH = static_cast<float>(gH); entry.glyphH = static_cast<float>(gH);
entry.advance = measureWidth(glyph); entry.advance = measureWidth(glyph);
if (entry.advance <= 0.0f) if (entry.advance <= 0.0f) {
{
entry.advance = entry.glyphW; entry.advance = entry.glyphW;
} }
} }
@@ -773,22 +682,17 @@ void ScrollerPage::ensureCharTexture(const std::string &glyph)
charTextures_[glyph] = entry; charTextures_[glyph] = entry;
} }
void ScrollerPage::clearCharTextures() void ScrollerPage::clearCharTextures() {
{ for (auto &entry : charTextures_) {
for (auto &entry : charTextures_) if (entry.second.surface) {
{
if (entry.second.surface)
{
SDL_DestroySurface(entry.second.surface); SDL_DestroySurface(entry.second.surface);
} }
} }
charTextures_.clear(); charTextures_.clear();
} }
float ScrollerPage::measureWidth(const std::string &text) const float ScrollerPage::measureWidth(const std::string &text) const {
{ if (!font_) {
if (!font_)
{
return 0.0f; return 0.0f;
} }
int w = 0; int w = 0;
@@ -797,9 +701,9 @@ float ScrollerPage::measureWidth(const std::string &text) const
return static_cast<float>(w); return static_cast<float>(w);
} }
std::string ScrollerPage::audioFilePath(const std::string &filename) const std::string ScrollerPage::audioFilePath(const std::string &filename) const {
{ std::string path1 =
std::string path1 = std::string(std::getenv("HOME") ? std::getenv("HOME") : "/tmp") + std::string(std::getenv("HOME") ? std::getenv("HOME") : "/tmp") +
"/.local/radiantwave/audio/" + filename; "/.local/radiantwave/audio/" + filename;
std::string path2 = "assets/audio/" + filename; std::string path2 = "assets/audio/" + filename;
return std::filesystem::exists(path1) ? path1 : path2; return std::filesystem::exists(path1) ? path1 : path2;
+34
View File
@@ -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);
}
+56
View File
@@ -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;