From d84307bd0e2e71257e25447c5109ad53aea091a5 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 27 Aug 2026 18:45:57 -0500 Subject: [PATCH 1/7] fixed (bug) Audio output device not switching --- src/audio/audio_manager.cpp | 251 ++++++++++++++++++++++++++++++++++-- src/audio/audio_manager.h | 21 +++ src/audio_settings_page.cpp | 19 ++- src/audio_settings_page.h | 4 + src/main.cpp | 7 + tests/test_audio.cpp | 23 ++++ 6 files changed, 312 insertions(+), 13 deletions(-) diff --git a/src/audio/audio_manager.cpp b/src/audio/audio_manager.cpp index 7fc16a8..c72b8ef 100644 --- a/src/audio/audio_manager.cpp +++ b/src/audio/audio_manager.cpp @@ -76,7 +76,6 @@ namespace radiantwave::audio enabled_ = cfg.audio.enabled; currentDevice_ = cfg.audio.output_device; currentTrack_ = cfg.audio.current_track; - if (!MIX_Init()) { std::string err = SDL_GetError(); @@ -86,17 +85,40 @@ namespace radiantwave::audio return false; } - // Create mixer device with default audio device - mixer_ = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr); + // Try to open the mixer on the device previously persisted in config; + // fall back to the system default when the configured device is gone. + SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK; + if (!currentDevice_.empty()) + { + int count = 0; + SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count); + if (devices) + { + for (int i = 0; i < count; ++i) + { + const char *name = SDL_GetAudioDeviceName(devices[i]); + if (name && currentDevice_ == name) + { + targetId = devices[i]; + break; + } + } + SDL_free(devices); + } + } + + mixer_ = MIX_CreateMixerDevice(targetId, nullptr); if (!mixer_) { std::string err = SDL_GetError(); logger::Logger::instance().warning("Audio", - "Could not open 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; return false; } + currentDeviceId_ = targetId; + slots_[0].track = MIX_CreateTrack(mixer_); slots_[1].track = MIX_CreateTrack(mixer_); initialized_ = true; @@ -181,18 +203,48 @@ namespace radiantwave::audio { const char *name = SDL_GetAudioDeviceName(devices[i]); 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); } else { - list.push_back({"", "Built-In Speakers"}); + list.push_back({"", "Built-In Speakers", 0}); } return list; } + std::string AudioManager::resolvePlaybackDeviceName(SDL_AudioDeviceID id) + { + if (id == 0 || id == SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK) + { + return ""; + } + + int count = 0; + SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count); + std::string found; + if (devices) + { + for (int i = 0; i < count; ++i) + { + if (devices[i] == id) + { + const char *name = SDL_GetAudioDeviceName(devices[i]); + found = name ? name : ""; + break; + } + } + SDL_free(devices); + } + return found; + } + void AudioManager::setVolume(int volume_0_to_100) { std::lock_guard lock(mutex_); @@ -253,14 +305,76 @@ namespace radiantwave::audio bool AudioManager::switchDevice(const std::string &raw_device_name) { std::lock_guard 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 lock(mutex_); + + // 0 is the synthetic "default / built-in" entry from getPlaybackDevices(). + if (id == 0) + { + id = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK; + } + + // No mixer yet: just record the choice for initialize(). + if (!mixer_ || !initialized_) + { + currentDeviceId_ = id; + currentDevice_ = resolvePlaybackDeviceName(id); + if (configManager_) + { + configManager_->update([name = currentDevice_](config::AppConfig &cfg) + { cfg.audio.output_device = name; }); + } + return true; + } + + if (!recreateMixerOnDevice(id)) + { + logger::Logger::instance().error("Audio", + "Failed to switch audio output device to: " + mapToFriendlyDeviceName(resolvePlaybackDeviceName(id))); + return false; + } + + currentDeviceId_ = id; + // After recreation SDL may have normalized the id (e.g. default). + if (SDL_GetAudioDeviceName(id) == nullptr && id != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK) + { + currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK; + } + currentDevice_ = resolvePlaybackDeviceName(currentDeviceId_); + + logger::Logger::instance().info("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(currentDevice_)); if (configManager_) { - configManager_->update([raw_device_name](config::AppConfig &cfg) - { cfg.audio.output_device = raw_device_name; }); + configManager_->update([name = currentDevice_](config::AppConfig &cfg) + { cfg.audio.output_device = name; }); } return true; @@ -272,6 +386,123 @@ namespace radiantwave::audio return currentDevice_; } + SDL_AudioDeviceID AudioManager::getCurrentDeviceId() const + { + std::lock_guard lock(mutex_); + return currentDeviceId_; + } + + bool AudioManager::handleDeviceEvent(const SDL_Event &event) + { + if (event.type != SDL_EVENT_AUDIO_DEVICE_ADDED && + event.type != SDL_EVENT_AUDIO_DEVICE_REMOVED) + { + return false; + } + + std::lock_guard lock(mutex_); + if (!mixer_ || !initialized_) + { + return false; + } + + // Only playback device events are relevant to the active output. + if (event.adevice.recording) + { + return false; + } + + const SDL_AudioDeviceID changedId = event.adevice.which; + + // If a specific device (not the default) was removed, fail over to the + // system default so playback keeps going on whatever remains. + if (event.type == SDL_EVENT_AUDIO_DEVICE_REMOVED && + currentDeviceId_ != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK && + changedId == currentDeviceId_) + { + logger::Logger::instance().warning("Audio", + "Active audio output device removed; failing over to the system default"); + if (recreateMixerOnDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)) + { + currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK; + currentDevice_ = ""; + } + return true; + } + + // A device arrived or a non-active device left: nothing to remap, but + // the audio settings page refreshes its list on its own timer. + return false; + } + + // Must be called with mutex_ held. Destroys the current mixer and creates a + // fresh one on the given device, recreating the two track slots and + // resuming the active track if one was playing. + bool AudioManager::recreateMixerOnDevice(SDL_AudioDeviceID targetId) + { + // Capture the playback state we want to preserve across the switch. + std::string resumeTrack = currentTrack_; + float fadeSeconds = slots_[activeSlot_].fadeInDuration > 0.0f + ? slots_[activeSlot_].fadeInDuration + : 0.0f; + + // Tear down the existing mixer (its tracks die with it). + for (int i = 0; i < 2; ++i) + { + slots_[i].track = nullptr; + if (slots_[i].audio) + { + MIX_DestroyAudio(slots_[i].audio); + slots_[i].audio = nullptr; + } + } + if (mixer_) + { + MIX_DestroyMixer(mixer_); + mixer_ = nullptr; + } + + mixer_ = MIX_CreateMixerDevice(targetId, nullptr); + if (!mixer_) + { + initialized_ = false; + return false; + } + + slots_[0].track = MIX_CreateTrack(mixer_); + slots_[1].track = MIX_CreateTrack(mixer_); + if (!slots_[0].track || !slots_[1].track) + { + MIX_DestroyMixer(mixer_); + mixer_ = nullptr; + initialized_ = false; + return false; + } + + float gain = enabled_ ? (volume_ / 100.0f) : 0.0f; + MIX_SetMixerGain(mixer_, gain); + + // Resume the previously active track at full gain (or restart its + // fade-in if it was still ramping). + if (!resumeTrack.empty()) + { + if (loadTrackInto(activeSlot_, resumeTrack)) + { + if (fadeSeconds > 0.0f) + { + startFadeIn(activeSlot_, fadeSeconds); + } + else + { + MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f); + MIX_PlayTrack(slots_[activeSlot_].track, 0); // Loop playback + } + } + } + + return true; + } + bool AudioManager::loadTrackInto(int slotIndex, const std::string &wav_filename) { if (!initialized_ || !mixer_ || !slots_[slotIndex].track) diff --git a/src/audio/audio_manager.h b/src/audio/audio_manager.h index fe5760d..404280d 100644 --- a/src/audio/audio_manager.h +++ b/src/audio/audio_manager.h @@ -1,6 +1,7 @@ #pragma once #include "config/config_manager.h" +#include #include #include #include @@ -15,6 +16,7 @@ namespace radiantwave::audio { struct AudioDeviceItem { std::string raw_name; // e.g. alsa_output.pci-0000_00_1f.3.analog-stereo 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 @@ -51,7 +53,18 @@ public: // Audio device selection bool switchDevice(const std::string& raw_device_name); + bool switchDeviceById(SDL_AudioDeviceID id); 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); // Playback control bool playTrack(const std::string& wav_filename); @@ -84,6 +97,13 @@ private: void startFadeIn(int slotIndex, float seconds); std::string displayNameFor(const std::string& wav_filename) const; + // 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; db::DatabaseManager* db_ = nullptr; @@ -91,6 +111,7 @@ private: bool enabled_ = true; int volume_ = 80; // 0 - 100 std::string currentDevice_ = ""; + SDL_AudioDeviceID currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK; std::string currentTrack_ = ""; MIX_Mixer* mixer_ = nullptr; diff --git a/src/audio_settings_page.cpp b/src/audio_settings_page.cpp index 73ca32f..7bad55a 100644 --- a/src/audio_settings_page.cpp +++ b/src/audio_settings_page.cpp @@ -77,7 +77,7 @@ void AudioSettingsPage::onPush(AppState& state) { } else if (modalState_ == ModalState::DeviceSelector) { if (!devices_.empty()) { currentDeviceIndex_ = modalSelectedIndex_; - state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name); + applyDeviceSelection(); } modalState_ = ModalState::None; } else if (modalState_ == ModalState::TrackSelector) { @@ -156,7 +156,7 @@ void AudioSettingsPage::onResume(AppState& state) { } else if (modalState_ == ModalState::DeviceSelector) { if (!devices_.empty()) { currentDeviceIndex_ = modalSelectedIndex_; - state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name); + applyDeviceSelection(); } modalState_ = ModalState::None; } else if (modalState_ == ModalState::TrackSelector) { @@ -280,6 +280,19 @@ void AudioSettingsPage::refreshDeviceAndTrackLists() { } } +void AudioSettingsPage::applyDeviceSelection() { + if (!state_) return; + if (currentDeviceIndex_ < 0 || currentDeviceIndex_ >= static_cast(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() { if (!state_) return; state_->config.update([](radiantwave::config::AppConfig& cfg) { @@ -324,7 +337,7 @@ void AudioSettingsPage::handleEvent(const SDL_Event& event) { if (modalState_ == ModalState::DeviceSelector) { if (!devices_.empty()) { currentDeviceIndex_ = modalSelectedIndex_; - state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name); + applyDeviceSelection(); } } else if (modalState_ == ModalState::TrackSelector) { if (modalSelectedIndex_ == 0) { diff --git a/src/audio_settings_page.h b/src/audio_settings_page.h index 7a2d4be..3ac7cd4 100644 --- a/src/audio_settings_page.h +++ b/src/audio_settings_page.h @@ -36,6 +36,10 @@ private: void enableDynamicAudio(); 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; int selectedItem_ = 0; // 0 = Volume, 1 = Device, 2 = Track, 3 = Status, 4 = Back diff --git a/src/main.cpp b/src/main.cpp index 7cee294..d097fcd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -196,6 +196,13 @@ SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) { 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) { radiantwave::logger::Logger::instance().info( "Graphics", "Window resized to " + diff --git a/tests/test_audio.cpp b/tests/test_audio.cpp index b14ab1d..04c2828 100644 --- a/tests/test_audio.cpp +++ b/tests/test_audio.cpp @@ -11,6 +11,29 @@ TEST(AudioTest, FriendlyDeviceNameMapping) { 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) { radiantwave::config::ConfigManager config; std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string(); -- 2.43.0 From 3f6201182e01b3f62fa2353147ddb680b2ec3a7a Mon Sep 17 00:00:00 2001 From: NeilClack Date: Thu, 27 Aug 2026 20:58:13 -0500 Subject: [PATCH 2/7] Publish live-update manifest to fileserver (dev) 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. --- .gitea/workflows/dev.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.gitea/workflows/dev.yml b/.gitea/workflows/dev.yml index e603c25..d03ead2 100644 --- a/.gitea/workflows/dev.yml +++ b/.gitea/workflows/dev.yml @@ -66,3 +66,17 @@ jobs: run: | attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN" 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 -- 2.43.0 From 2917d33866f052eca1e9daea175d95382a9087f2 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 28 Aug 2026 13:55:20 -0500 Subject: [PATCH 3/7] fixed (bug) Audio not looping after ending. --- src/audio/audio_manager.cpp | 100 +++++++++++++++++++++++++------- src/audio/audio_manager.h | 11 ++++ tests/test_audio.cpp | 11 ++++ tests/test_scroller_streams.cpp | 56 ++++++++++++++++++ 4 files changed, 156 insertions(+), 22 deletions(-) diff --git a/src/audio/audio_manager.cpp b/src/audio/audio_manager.cpp index c72b8ef..b5e59a9 100644 --- a/src/audio/audio_manager.cpp +++ b/src/audio/audio_manager.cpp @@ -157,6 +157,40 @@ namespace radiantwave::audio return enabled_ ? (volume_ / 100.0f) : 0.0f; } + SDL_PropertiesID AudioManager::createInfiniteLoopOptions() + { + // MIX_PlayTrack()'s options default to a single pass; requesting + // infinite loops is what keeps a track going until it is explicitly + // faded out or the app shuts down. + SDL_PropertiesID props = SDL_CreateProperties(); + if (props) + { + SDL_SetNumberProperty(props, MIX_PROP_PLAY_LOOPS_NUMBER, -1); + } + return props; + } + + bool AudioManager::playTrackLooping(int slotIndex) + { + if (!initialized_ || !mixer_ || slotIndex < 0 || slotIndex >= 2 || + !slots_[slotIndex].track) + { + return false; + } + + SDL_PropertiesID props = createInfiniteLoopOptions(); + if (!props) + { + logger::Logger::instance().error( + "Audio", "Failed to create playback options for " + + displayNameFor(currentTrack_)); + return false; + } + bool ok = MIX_PlayTrack(slots_[slotIndex].track, props); + SDL_DestroyProperties(props); + return ok; + } + void AudioManager::shutdown() { std::lock_guard lock(mutex_); @@ -495,7 +529,7 @@ namespace radiantwave::audio else { MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f); - MIX_PlayTrack(slots_[activeSlot_].track, 0); // Loop playback + playTrackLooping(activeSlot_); // Loop playback } } } @@ -553,7 +587,7 @@ namespace radiantwave::audio slot.fadeInDuration = seconds; slot.fadeInElapsed = 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)"); } @@ -567,7 +601,7 @@ namespace radiantwave::audio return false; 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 // restart and is used whenever dynamic audio is turned off. Dynamic @@ -604,10 +638,11 @@ namespace radiantwave::audio slot.fadeInElapsed = 0.0f; slot.fadeOutRemaining = 0.0f; MIX_SetTrackGain(slot.track, 1.0f); - if (!MIX_TrackPlaying(slot.track)) - { - MIX_PlayTrack(slot.track, 0); // Loop playback - } + // Always restart: MIX_PlayTrack() re-starts a playing track too, so + // this cancels any in-flight stop-fade inherited from a quick + // pop->push within the fade window. Gating on MIX_TrackPlaying() + // left a fading track to finish its fade and go silently stopped. + playTrackLooping(activeSlot_); // Loop playback return true; } @@ -642,10 +677,14 @@ namespace radiantwave::audio if (!loadTrackInto(otherSlot, wav_filename)) 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_]; if (active.audio) { + active.fadeInDuration = 0.0f; + active.fadeInElapsed = 0.0f; MIX_StopTrack(active.track, MIX_TrackMSToFrames(active.track, static_cast(fade * 1000.0))); active.fadeOutRemaining = fade; } @@ -673,6 +712,10 @@ namespace radiantwave::audio { continue; } + // The mixer owns the ramp now; drop any pending fade-in so the + // per-frame update() cannot fight the fade-out. + slot.fadeInDuration = 0.0f; + slot.fadeInElapsed = 0.0f; MIX_StopTrack(slot.track, MIX_TrackMSToFrames(slot.track, static_cast(fade * 1000.0))); slot.fadeOutRemaining = fade; faded = true; @@ -707,7 +750,29 @@ namespace radiantwave::audio { TrackSlot &slot = slots_[i]; if (!slot.track) + { continue; + } + + // A slot mid stop-fade belongs to the mixer: skip both the fade-in + // ramp and the active-gain pin so nothing drives its gain up while + // the mixer ramps it down (this also keeps a quit/pop fade clean). + if (slot.fadeOutRemaining > 0.0f) + { + slot.fadeOutRemaining -= dt; + if (slot.fadeOutRemaining <= 0.0f) + { + slot.fadeOutRemaining = 0.0f; + // Free the audio once the fade-out has finished; the track + // object itself is reused. + if (slot.audio) + { + MIX_DestroyAudio(slot.audio); + slot.audio = nullptr; + } + } + continue; + } if (slot.fadeInDuration > 0.0f) { @@ -724,21 +789,12 @@ namespace radiantwave::audio // Keep the active track at full per-track gain; the mixer gain // carries the user's volume / mute state. MIX_SetTrackGain(slot.track, 1.0f); - } - - if (slot.fadeOutRemaining > 0.0f) - { - slot.fadeOutRemaining -= dt; - if (slot.fadeOutRemaining <= 0.0f) + // Self-heal: playback must continue indefinitely, so restart a + // track that stopped unexpectedly (there is no pause state and + // nothing else should legitimately stop the active track). + if (slot.audio && enabled_ && !MIX_TrackPlaying(slot.track)) { - 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; - } + playTrackLooping(i); } } } diff --git a/src/audio/audio_manager.h b/src/audio/audio_manager.h index 404280d..082a693 100644 --- a/src/audio/audio_manager.h +++ b/src/audio/audio_manager.h @@ -66,6 +66,12 @@ public: // 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 bool playTrack(const std::string& wav_filename); void stopTrack(); @@ -97,6 +103,11 @@ private: void startFadeIn(int slotIndex, float seconds); 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 diff --git a/tests/test_audio.cpp b/tests/test_audio.cpp index 04c2828..6e319e5 100644 --- a/tests/test_audio.cpp +++ b/tests/test_audio.cpp @@ -59,3 +59,14 @@ TEST(AudioTest, AudioManagerVolumeClamping) { am.shutdown(); 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); +} diff --git a/tests/test_scroller_streams.cpp b/tests/test_scroller_streams.cpp index 8156aa5..f0a9736 100644 --- a/tests/test_scroller_streams.cpp +++ b/tests/test_scroller_streams.cpp @@ -94,6 +94,62 @@ TEST(ScrollerStreamsTest, InterleavesSelectedWithDefault) 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) { ProclamationRecord p1; -- 2.43.0 From bf2d4d1f890c868076222d401de9313f6beb1e65 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 28 Aug 2026 15:03:58 -0500 Subject: [PATCH 4/7] Update build dependencies to properly link dynamically. --- CMakeLists.txt | 9 ++++++++- nixos/vm.nix | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 244d966..1c302fd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,7 +112,14 @@ add_executable(test_provisioning tests/test_provisioning.cpp ) target_include_directories(test_provisioning PRIVATE src) -target_link_libraries(test_provisioning PRIVATE SDL3::SDL3) +# provisioning.h pulls in app_state.h, so the test compiles against the SDL3, +# SDL3_mixer, and SQLite headers transitively (app_state.h -> audio_manager.h -> +# SDL3_mixer; app_state.h -> database_manager.h -> sqlite3.h). +target_link_libraries(test_provisioning PRIVATE + SDL3::SDL3 + SDL3_mixer::SDL3_mixer + SQLite3::SQLite3 +) add_executable(test_assets tests/test_assets.cpp diff --git a/nixos/vm.nix b/nixos/vm.nix index db9ed59..3f7ecc9 100644 --- a/nixos/vm.nix +++ b/nixos/vm.nix @@ -112,6 +112,18 @@ in export WLR_RENDERER=pixman export WLR_NO_HARDWARE_CURSORS=1 + # Prefer the PipeWire audio backend so SDL enumerates every sink + # (built-in analog, HDMI, and the loopback virtual speaker) instead of + # silently falling back to an ALSA-only device view that hides the + # PipeWire sinks and makes the device selector show a single entry. + export SDL_AUDIODRIVER=pipewire + if [ -n "$${XDG_RUNTIME_DIR:-}" ]; then + for i in {1..20}; do + [ -S "$XDG_RUNTIME_DIR/pipewire-0" ] && break + sleep 0.5 + done + fi + cage -- ${radiantWaveApp}/bin/radiantwave > /tmp/kiosk.log 2>&1 fi ''; -- 2.43.0 From ca7b8b7e6faf063da9eb7d39bb5e929e9c705e0e Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 28 Aug 2026 15:04:42 -0500 Subject: [PATCH 5/7] Update audio pipeline with extensive logging and defualt with fallbacks --- src/audio/audio_manager.cpp | 45 ++++++++++++++++++++++++++++++++++++- src/audio_settings_page.cpp | 2 +- tests/test_audio.cpp | 2 ++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/audio/audio_manager.cpp b/src/audio/audio_manager.cpp index b5e59a9..d7cc723 100644 --- a/src/audio/audio_manager.cpp +++ b/src/audio/audio_manager.cpp @@ -32,6 +32,7 @@ namespace radiantwave::audio if (lower.find("analog") != std::string::npos || lower.find("built-in") != std::string::npos || lower.find("builtin") != std::string::npos || + lower.find("virtual") != std::string::npos || lower.find("realtek") != std::string::npos || lower.find("hda") != std::string::npos || lower.find("speaker") != std::string::npos || @@ -85,6 +86,34 @@ namespace radiantwave::audio return false; } + // Startup diagnostics: which audio backend SDL picked (PipeWire, ALSA, + // Pulse, dummy, ...) and what playback devices it actually sees. This is + // the first place a "only the built-in speakers show up" report points; + // a single device here means SDL itself never enumerated the HDMI sink. + const char *driver = SDL_GetCurrentAudioDriver(); + std::string driverName = driver ? driver : "(none)"; + int deviceCount = 0; + SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&deviceCount); + std::string deviceNames; + if (devices) + { + for (int i = 0; i < deviceCount; ++i) + { + if (i) + { + deviceNames += ", "; + } + const char *n = SDL_GetAudioDeviceName(devices[i]); + deviceNames += n ? n : "(unnamed)"; + } + SDL_free(devices); + } + logger::Logger::instance().info( + "Audio", + "SDL audio driver: " + driverName + "; " + + std::to_string(deviceCount) + " playback device(s) [" + + (deviceNames.empty() ? "(none)" : deviceNames) + "]"); + // Try to open the mixer on the device previously persisted in config; // fall back to the system default when the configured device is gone. SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK; @@ -247,7 +276,21 @@ namespace radiantwave::audio } else { - list.push_back({"", "Built-In Speakers", 0}); + if (devices) + { + SDL_free(devices); + } + const char *driver = SDL_GetCurrentAudioDriver(); + // Zero enumerated devices is a symptom, not a real output. Surface + // the truth (a single "System Default" entry that still routes to + // whatever the OS default is) instead of pretending a fabricated + // device, and say which backend was active so kiosk logs show why. + logger::Logger::instance().debug( + "Audio", + "No playback devices enumerated (audio driver: " + + std::string(driver ? driver : "none") + + "); falling back to the system default"); + list.push_back({"", "System Default", 0}); } return list; diff --git a/src/audio_settings_page.cpp b/src/audio_settings_page.cpp index 7bad55a..f28aa3a 100644 --- a/src/audio_settings_page.cpp +++ b/src/audio_settings_page.cpp @@ -531,7 +531,7 @@ void AudioSettingsPage::render(SDL_Renderer* renderer) { } std::string devName = (currentDeviceIndex_ >= 0 && currentDeviceIndex_ < static_cast(devices_.size())) - ? devices_[currentDeviceIndex_].friendly_name : "Built-In Speakers"; + ? devices_[currentDeviceIndex_].friendly_name : "System Default"; deviceText_.ensure(renderer, fontHeader_, "Device: " + devName, white); if (deviceText_.texture) { SDL_FRect dst{500.0f, currentY, deviceText_.width, deviceText_.height}; diff --git a/tests/test_audio.cpp b/tests/test_audio.cpp index 6e319e5..e35c4d6 100644 --- a/tests/test_audio.cpp +++ b/tests/test_audio.cpp @@ -8,6 +8,7 @@ TEST(AudioTest, FriendlyDeviceNameMapping) { EXPECT_EQ(mapToFriendlyDeviceName("AMD Radeon HD Audio Controller HDMI"), "TV / HDMI Monitor"); EXPECT_EQ(mapToFriendlyDeviceName("USB Audio Headset Dongle"), "USB Audio Device"); EXPECT_EQ(mapToFriendlyDeviceName("bluez_sink.00_11_22_33_44_55"), "Bluetooth Audio"); + EXPECT_EQ(mapToFriendlyDeviceName("Virtual Speaker"), "Built-In Speakers"); EXPECT_EQ(mapToFriendlyDeviceName(""), "Default Audio Output"); } @@ -30,6 +31,7 @@ TEST(AudioTest, GetPlaybackDevicesFallsBackWithIdZeroWhenNone) { for (const auto& d : devices) { if (d.raw_name.empty()) { EXPECT_EQ(d.id, 0u); + EXPECT_EQ(d.friendly_name, "System Default"); } } } -- 2.43.0 From d60a8bd7fb96086eedb4e8e15498a28be4f4efac Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 28 Aug 2026 15:35:57 -0500 Subject: [PATCH 6/7] Add WiFi prompt --- src/home.cpp | 24 ++++++++++++++++++++++++ src/home.h | 1 + 2 files changed, 25 insertions(+) diff --git a/src/home.cpp b/src/home.cpp index cc7eb77..ad9e384 100644 --- a/src/home.cpp +++ b/src/home.cpp @@ -180,6 +180,16 @@ void Home::render(SDL_Renderer *renderer) SDL_RenderFillRect(renderer, &line); } + // Large bold reminder to connect WiFi, drawn below the menu while no + // network is joined (refreshed every 5s in update()). + if (wifiNote_.texture && ssid_.empty()) + { + float noteX = (fw - wifiNote_.width) / 2.0f; + float noteY = settingsY_ + settings_.height + 40.0f; + SDL_FRect noteDst{noteX, noteY, wifiNote_.width, wifiNote_.height}; + SDL_RenderTexture(renderer, wifiNote_.texture, nullptr, ¬eDst); + } + // Bottom-right status footer (20pt, aligned to a virtual rectangle: // names left-aligned at the rect's left edge, values right-aligned at // the rect's right edge; the longest name's colon sits 4 spaces from its @@ -352,6 +362,15 @@ void Home::createTextures(AppState &state) TTF_CloseFont(font); + font = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 48); + if (font) + { + TTF_SetFontStyle(font, TTF_STYLE_BOLD); + wifiNote_ = renderText(state.renderer, font, "Please connect to WiFi", white); + TTF_SetFontStyle(font, TTF_STYLE_NORMAL); + TTF_CloseFont(font); + } + SDL_Surface *bgSurface = IMG_Load("assets/images/RWT-Circular-Color-ForOnDark-NoText.png"); if (bgSurface) { @@ -410,6 +429,11 @@ void Home::destroyTextures() SDL_DestroyTexture(settings_.texture); settings_.texture = nullptr; } + if (wifiNote_.texture) + { + SDL_DestroyTexture(wifiNote_.texture); + wifiNote_.texture = nullptr; + } for (auto &t : footerNames_) t.destroy(); for (auto &t : footerValues_) diff --git a/src/home.h b/src/home.h index 40d04ce..c56db1b 100644 --- a/src/home.h +++ b/src/home.h @@ -34,6 +34,7 @@ private: TextLine play_; TextLine settings_; TextLine bg_; + TextLine wifiNote_; AppState* state_ = nullptr; float playY_ = 0; -- 2.43.0 From 42b915bd25750f69184aeae7df3aa1665f17944d Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 28 Aug 2026 16:18:36 -0500 Subject: [PATCH 7/7] fixed (bug) Audio device switch killing playback via stale SDL device id recreateMixerOnDevice destroyed the active mixer before opening the replacement. MIX_DestroyMixer quits SDL audio at refcount 0, tearing down the device registry and renumbering device ids, so the cached id being reopened went stale ('Invalid audio device instance ID') and the app went permanently mute. Open the new mixer first while the old device is alive, then tear the old one down; a failed switch now keeps the current output playing instead of killing audio. Also log SDL_GetError() on open failure. --- src/audio/audio_manager.cpp | 67 ++++++++++++++++++++++++++----------- src/audio/audio_manager.h | 7 ++-- src/audio_settings_page.cpp | 2 +- 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/src/audio/audio_manager.cpp b/src/audio/audio_manager.cpp index d7cc723..a62c14c 100644 --- a/src/audio/audio_manager.cpp +++ b/src/audio/audio_manager.cpp @@ -512,9 +512,19 @@ namespace radiantwave::audio 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. + // Must be called with mutex_ held. Creates a fresh mixer on the given + // device, recreating the two track slots and resuming the active track if + // one was playing. + // + // The new mixer is created BEFORE the current one is destroyed. Reversing + // the order (destroy-then-reopen) quits the whole SDL audio subsystem the + // moment the last device closes: MIX_DestroyMixer() follows up with + // SDL_QuitSubSystem(SDL_INIT_AUDIO), which tears down SDL's device registry + // and renumbers every enumerated device id on the way back up. The id this + // switch was asked to open is then stale and the reopen fails with + // "Invalid audio device instance ID". Keeping the current device open while + // the replacement opens also means a failed switch leaves the current + // output playing instead of going permanently mute. bool AudioManager::recreateMixerOnDevice(SDL_AudioDeviceID targetId) { // Capture the playback state we want to preserve across the switch. @@ -523,7 +533,37 @@ namespace radiantwave::audio ? slots_[activeSlot_].fadeInDuration : 0.0f; - // Tear down the existing mixer (its tracks die with it). + // Open the replacement mixer on the target device first, while the + // current device (and SDL's device registry) is still alive. + MIX_Mixer *newMixer = MIX_CreateMixerDevice(targetId, nullptr); + if (!newMixer) + { + std::string err = SDL_GetError(); + logger::Logger::instance().error( + "Audio", + "Could not open audio output device " + + mapToFriendlyDeviceName(resolvePlaybackDeviceName(targetId)) + + "; keeping the current device (MIX_CreateMixerDevice error: " + + err + ")"); + return false; + } + + MIX_Track *newTracks[2]; + for (int i = 0; i < 2; ++i) + { + newTracks[i] = MIX_CreateTrack(newMixer); + } + if (!newTracks[0] || !newTracks[1]) + { + MIX_DestroyMixer(newMixer); + logger::Logger::instance().error( + "Audio", "Could not create mixer tracks; keeping the current device (SDL error: " + + std::string(SDL_GetError()) + ")"); + return false; + } + + // Tear down the existing mixer (its tracks die with it) and adopt the + // new one. for (int i = 0; i < 2; ++i) { slots_[i].track = nullptr; @@ -539,22 +579,9 @@ namespace radiantwave::audio 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; - } + mixer_ = newMixer; + slots_[0].track = newTracks[0]; + slots_[1].track = newTracks[1]; float gain = enabled_ ? (volume_ / 100.0f) : 0.0f; MIX_SetMixerGain(mixer_, gain); diff --git a/src/audio/audio_manager.h b/src/audio/audio_manager.h index 082a693..33ee175 100644 --- a/src/audio/audio_manager.h +++ b/src/audio/audio_manager.h @@ -111,8 +111,11 @@ private: // 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). + // in-progress fade is restarted. The replacement mixer is opened before the + // current one is destroyed so SDL's device ids stay valid (destroy-first + // quits the whole audio subsystem and orphans the id being opened). + // Returns false on failure without touching the current mixer, so the + // existing output keeps playing. bool recreateMixerOnDevice(SDL_AudioDeviceID targetId); config::ConfigManager* configManager_ = nullptr; diff --git a/src/audio_settings_page.cpp b/src/audio_settings_page.cpp index f28aa3a..90f9738 100644 --- a/src/audio_settings_page.cpp +++ b/src/audio_settings_page.cpp @@ -288,7 +288,7 @@ void AudioSettingsPage::applyDeviceSelection() { if (ok) { statusMessage_ = "Switched to " + devices_[currentDeviceIndex_].friendly_name; } else { - statusMessage_ = "Device unavailable - using system default"; + statusMessage_ = "Device unavailable - kept the current output"; } refreshDeviceAndTrackLists(); } -- 2.43.0