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 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 ''; diff --git a/src/audio/audio_manager.cpp b/src/audio/audio_manager.cpp index 7fc16a8..a62c14c 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 || @@ -76,7 +77,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 +86,68 @@ namespace radiantwave::audio return false; } - // Create mixer device with default audio device - mixer_ = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr); + // Startup diagnostics: which audio backend SDL picked (PipeWire, ALSA, + // Pulse, dummy, ...) and what playback devices it actually sees. This is + // the first place a "only the built-in speakers show up" report points; + // a single device here means SDL itself never enumerated the HDMI sink. + const char *driver = SDL_GetCurrentAudioDriver(); + std::string driverName = driver ? driver : "(none)"; + int deviceCount = 0; + SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&deviceCount); + std::string deviceNames; + if (devices) + { + for (int i = 0; i < deviceCount; ++i) + { + if (i) + { + deviceNames += ", "; + } + const char *n = SDL_GetAudioDeviceName(devices[i]); + deviceNames += n ? n : "(unnamed)"; + } + SDL_free(devices); + } + logger::Logger::instance().info( + "Audio", + "SDL audio driver: " + driverName + "; " + + std::to_string(deviceCount) + " playback device(s) [" + + (deviceNames.empty() ? "(none)" : deviceNames) + "]"); + + // Try to open the mixer on the device previously persisted in config; + // fall back to the system default when the configured device is gone. + SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK; + if (!currentDevice_.empty()) + { + int count = 0; + SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count); + if (devices) + { + for (int i = 0; i < count; ++i) + { + const char *name = SDL_GetAudioDeviceName(devices[i]); + if (name && currentDevice_ == name) + { + targetId = devices[i]; + break; + } + } + SDL_free(devices); + } + } + + mixer_ = MIX_CreateMixerDevice(targetId, nullptr); if (!mixer_) { std::string err = SDL_GetError(); logger::Logger::instance().warning("Audio", - "Could not open 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; @@ -135,6 +186,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_); @@ -181,18 +266,62 @@ 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"}); + if (devices) + { + SDL_free(devices); + } + const char *driver = SDL_GetCurrentAudioDriver(); + // Zero enumerated devices is a symptom, not a real output. Surface + // the truth (a single "System Default" entry that still routes to + // whatever the OS default is) instead of pretending a fabricated + // device, and say which backend was active so kiosk logs show why. + logger::Logger::instance().debug( + "Audio", + "No playback devices enumerated (audio driver: " + + std::string(driver ? driver : "none") + + "); falling back to the system default"); + list.push_back({"", "System Default", 0}); } return list; } + std::string AudioManager::resolvePlaybackDeviceName(SDL_AudioDeviceID id) + { + if (id == 0 || id == SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK) + { + return ""; + } + + int count = 0; + SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count); + std::string found; + if (devices) + { + for (int i = 0; i < count; ++i) + { + if (devices[i] == id) + { + const char *name = SDL_GetAudioDeviceName(devices[i]); + found = name ? name : ""; + break; + } + } + SDL_free(devices); + } + return found; + } + void AudioManager::setVolume(int volume_0_to_100) { std::lock_guard lock(mutex_); @@ -253,14 +382,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 +463,150 @@ 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. Creates a fresh mixer on the given + // device, recreating the two track slots and resuming the active track if + // one was playing. + // + // The new mixer is created BEFORE the current one is destroyed. Reversing + // the order (destroy-then-reopen) quits the whole SDL audio subsystem the + // moment the last device closes: MIX_DestroyMixer() follows up with + // SDL_QuitSubSystem(SDL_INIT_AUDIO), which tears down SDL's device registry + // and renumbers every enumerated device id on the way back up. The id this + // switch was asked to open is then stale and the reopen fails with + // "Invalid audio device instance ID". Keeping the current device open while + // the replacement opens also means a failed switch leaves the current + // output playing instead of going permanently mute. + bool AudioManager::recreateMixerOnDevice(SDL_AudioDeviceID targetId) + { + // Capture the playback state we want to preserve across the switch. + std::string resumeTrack = currentTrack_; + float fadeSeconds = slots_[activeSlot_].fadeInDuration > 0.0f + ? slots_[activeSlot_].fadeInDuration + : 0.0f; + + // Open the replacement mixer on the target device first, while the + // current device (and SDL's device registry) is still alive. + MIX_Mixer *newMixer = MIX_CreateMixerDevice(targetId, nullptr); + if (!newMixer) + { + std::string err = SDL_GetError(); + logger::Logger::instance().error( + "Audio", + "Could not open audio output device " + + mapToFriendlyDeviceName(resolvePlaybackDeviceName(targetId)) + + "; keeping the current device (MIX_CreateMixerDevice error: " + + err + ")"); + return false; + } + + MIX_Track *newTracks[2]; + for (int i = 0; i < 2; ++i) + { + newTracks[i] = MIX_CreateTrack(newMixer); + } + if (!newTracks[0] || !newTracks[1]) + { + MIX_DestroyMixer(newMixer); + logger::Logger::instance().error( + "Audio", "Could not create mixer tracks; keeping the current device (SDL error: " + + std::string(SDL_GetError()) + ")"); + return false; + } + + // Tear down the existing mixer (its tracks die with it) and adopt the + // new one. + for (int i = 0; i < 2; ++i) + { + slots_[i].track = nullptr; + if (slots_[i].audio) + { + MIX_DestroyAudio(slots_[i].audio); + slots_[i].audio = nullptr; + } + } + if (mixer_) + { + MIX_DestroyMixer(mixer_); + mixer_ = nullptr; + } + + mixer_ = newMixer; + slots_[0].track = newTracks[0]; + slots_[1].track = newTracks[1]; + + float gain = enabled_ ? (volume_ / 100.0f) : 0.0f; + MIX_SetMixerGain(mixer_, gain); + + // Resume the previously active track at full gain (or restart its + // fade-in if it was still ramping). + if (!resumeTrack.empty()) + { + if (loadTrackInto(activeSlot_, resumeTrack)) + { + if (fadeSeconds > 0.0f) + { + startFadeIn(activeSlot_, fadeSeconds); + } + else + { + MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f); + playTrackLooping(activeSlot_); // Loop playback + } + } + } + + return true; + } + bool AudioManager::loadTrackInto(int slotIndex, const std::string &wav_filename) { if (!initialized_ || !mixer_ || !slots_[slotIndex].track) @@ -322,7 +657,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)"); } @@ -336,7 +671,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 @@ -373,10 +708,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; } @@ -411,10 +747,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; } @@ -442,6 +782,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; @@ -476,7 +820,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) { @@ -493,21 +859,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 fe5760d..33ee175 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,24 @@ 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); + + // 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); @@ -84,6 +103,21 @@ 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 + // 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; db::DatabaseManager* db_ = nullptr; @@ -91,6 +125,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..90f9738 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 - kept the current output"; + } + 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) { @@ -518,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/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/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; 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..e35c4d6 100644 --- a/tests/test_audio.cpp +++ b/tests/test_audio.cpp @@ -8,9 +8,34 @@ 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"); } +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); + EXPECT_EQ(d.friendly_name, "System Default"); + } + } +} + TEST(AudioTest, AudioManagerVolumeClamping) { radiantwave::config::ConfigManager config; std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string(); @@ -36,3 +61,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;