Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5369532ab4 | ||
|
|
42b915bd25 | ||
|
|
d60a8bd7fb | ||
|
|
ca7b8b7e6f | ||
|
|
bf2d4d1f89 | ||
|
|
2917d33866 | ||
|
|
3f6201182e |
@@ -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
|
||||||
|
|||||||
+8
-1
@@ -112,7 +112,14 @@ add_executable(test_provisioning
|
|||||||
tests/test_provisioning.cpp
|
tests/test_provisioning.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(test_provisioning PRIVATE src)
|
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
|
add_executable(test_assets
|
||||||
tests/test_assets.cpp
|
tests/test_assets.cpp
|
||||||
|
|||||||
@@ -112,6 +112,18 @@ in
|
|||||||
export WLR_RENDERER=pixman
|
export WLR_RENDERER=pixman
|
||||||
export WLR_NO_HARDWARE_CURSORS=1
|
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
|
cage -- ${radiantWaveApp}/bin/radiantwave > /tmp/kiosk.log 2>&1
|
||||||
fi
|
fi
|
||||||
'';
|
'';
|
||||||
|
|||||||
+169
-43
@@ -32,6 +32,7 @@ namespace radiantwave::audio
|
|||||||
if (lower.find("analog") != std::string::npos ||
|
if (lower.find("analog") != std::string::npos ||
|
||||||
lower.find("built-in") != std::string::npos ||
|
lower.find("built-in") != std::string::npos ||
|
||||||
lower.find("builtin") != std::string::npos ||
|
lower.find("builtin") != std::string::npos ||
|
||||||
|
lower.find("virtual") != std::string::npos ||
|
||||||
lower.find("realtek") != std::string::npos ||
|
lower.find("realtek") != std::string::npos ||
|
||||||
lower.find("hda") != std::string::npos ||
|
lower.find("hda") != std::string::npos ||
|
||||||
lower.find("speaker") != std::string::npos ||
|
lower.find("speaker") != std::string::npos ||
|
||||||
@@ -85,6 +86,34 @@ namespace radiantwave::audio
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Startup diagnostics: which audio backend SDL picked (PipeWire, ALSA,
|
||||||
|
// Pulse, dummy, ...) and what playback devices it actually sees. This is
|
||||||
|
// the first place a "only the built-in speakers show up" report points;
|
||||||
|
// a single device here means SDL itself never enumerated the HDMI sink.
|
||||||
|
const char *driver = SDL_GetCurrentAudioDriver();
|
||||||
|
std::string driverName = driver ? driver : "(none)";
|
||||||
|
int deviceCount = 0;
|
||||||
|
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&deviceCount);
|
||||||
|
std::string deviceNames;
|
||||||
|
if (devices)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < deviceCount; ++i)
|
||||||
|
{
|
||||||
|
if (i)
|
||||||
|
{
|
||||||
|
deviceNames += ", ";
|
||||||
|
}
|
||||||
|
const char *n = SDL_GetAudioDeviceName(devices[i]);
|
||||||
|
deviceNames += n ? n : "(unnamed)";
|
||||||
|
}
|
||||||
|
SDL_free(devices);
|
||||||
|
}
|
||||||
|
logger::Logger::instance().info(
|
||||||
|
"Audio",
|
||||||
|
"SDL audio driver: " + driverName + "; " +
|
||||||
|
std::to_string(deviceCount) + " playback device(s) [" +
|
||||||
|
(deviceNames.empty() ? "(none)" : deviceNames) + "]");
|
||||||
|
|
||||||
// Try to open the mixer on the device previously persisted in config;
|
// Try to open the mixer on the device previously persisted in config;
|
||||||
// fall back to the system default when the configured device is gone.
|
// fall back to the system default when the configured device is gone.
|
||||||
SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||||
@@ -157,6 +186,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_);
|
||||||
@@ -213,7 +276,21 @@ namespace radiantwave::audio
|
|||||||
}
|
}
|
||||||
else
|
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;
|
return list;
|
||||||
@@ -435,9 +512,19 @@ namespace radiantwave::audio
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must be called with mutex_ held. Destroys the current mixer and creates a
|
// Must be called with mutex_ held. Creates a fresh mixer on the given
|
||||||
// fresh one on the given device, recreating the two track slots and
|
// device, recreating the two track slots and resuming the active track if
|
||||||
// resuming the active track if one was playing.
|
// 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)
|
bool AudioManager::recreateMixerOnDevice(SDL_AudioDeviceID targetId)
|
||||||
{
|
{
|
||||||
// Capture the playback state we want to preserve across the switch.
|
// Capture the playback state we want to preserve across the switch.
|
||||||
@@ -446,7 +533,37 @@ namespace radiantwave::audio
|
|||||||
? slots_[activeSlot_].fadeInDuration
|
? slots_[activeSlot_].fadeInDuration
|
||||||
: 0.0f;
|
: 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)
|
for (int i = 0; i < 2; ++i)
|
||||||
{
|
{
|
||||||
slots_[i].track = nullptr;
|
slots_[i].track = nullptr;
|
||||||
@@ -462,22 +579,9 @@ namespace radiantwave::audio
|
|||||||
mixer_ = nullptr;
|
mixer_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
mixer_ = MIX_CreateMixerDevice(targetId, nullptr);
|
mixer_ = newMixer;
|
||||||
if (!mixer_)
|
slots_[0].track = newTracks[0];
|
||||||
{
|
slots_[1].track = newTracks[1];
|
||||||
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;
|
float gain = enabled_ ? (volume_ / 100.0f) : 0.0f;
|
||||||
MIX_SetMixerGain(mixer_, gain);
|
MIX_SetMixerGain(mixer_, gain);
|
||||||
@@ -495,7 +599,7 @@ namespace radiantwave::audio
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -553,7 +657,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)");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +671,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
|
||||||
@@ -604,10 +708,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,10 +747,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;
|
||||||
}
|
}
|
||||||
@@ -673,6 +782,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;
|
||||||
@@ -707,7 +820,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)
|
||||||
{
|
{
|
||||||
@@ -724,21 +859,12 @@ namespace radiantwave::audio
|
|||||||
// Keep the active track at full per-track gain; the mixer gain
|
// Keep the active track at full per-track gain; the mixer gain
|
||||||
// carries the user's volume / mute state.
|
// carries the user's volume / mute state.
|
||||||
MIX_SetTrackGain(slot.track, 1.0f);
|
MIX_SetTrackGain(slot.track, 1.0f);
|
||||||
}
|
// Self-heal: playback must continue indefinitely, so restart a
|
||||||
|
// track that stopped unexpectedly (there is no pause state and
|
||||||
if (slot.fadeOutRemaining > 0.0f)
|
// nothing else should legitimately stop the active track).
|
||||||
{
|
if (slot.audio && enabled_ && !MIX_TrackPlaying(slot.track))
|
||||||
slot.fadeOutRemaining -= dt;
|
|
||||||
if (slot.fadeOutRemaining <= 0.0f)
|
|
||||||
{
|
{
|
||||||
slot.fadeOutRemaining = 0.0f;
|
playTrackLooping(i);
|
||||||
// Free the audio once the fade-out has finished; the track
|
|
||||||
// object itself is reused.
|
|
||||||
if (slot.audio)
|
|
||||||
{
|
|
||||||
MIX_DestroyAudio(slot.audio);
|
|
||||||
slot.audio = nullptr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ public:
|
|||||||
// for tests; the real lookup is through getPlaybackDevices().
|
// for tests; the real lookup is through getPlaybackDevices().
|
||||||
static std::string resolvePlaybackDeviceName(SDL_AudioDeviceID id);
|
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);
|
||||||
void stopTrack();
|
void stopTrack();
|
||||||
@@ -97,11 +103,19 @@ 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).
|
// Recreates the mixer on the given playback device id (0 = system default).
|
||||||
// Called with mutex_ held. Preserves playback state: whichever track was
|
// Called with mutex_ held. Preserves playback state: whichever track was
|
||||||
// playing on the active slot is reloaded and resumed at full gain, an
|
// 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
|
// in-progress fade is restarted. The replacement mixer is opened before the
|
||||||
// left destroyed but the app remains safe to keep running muted).
|
// 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);
|
bool recreateMixerOnDevice(SDL_AudioDeviceID targetId);
|
||||||
|
|
||||||
config::ConfigManager* configManager_ = nullptr;
|
config::ConfigManager* configManager_ = nullptr;
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ void AudioSettingsPage::applyDeviceSelection() {
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
statusMessage_ = "Switched to " + devices_[currentDeviceIndex_].friendly_name;
|
statusMessage_ = "Switched to " + devices_[currentDeviceIndex_].friendly_name;
|
||||||
} else {
|
} else {
|
||||||
statusMessage_ = "Device unavailable - using system default";
|
statusMessage_ = "Device unavailable - kept the current output";
|
||||||
}
|
}
|
||||||
refreshDeviceAndTrackLists();
|
refreshDeviceAndTrackLists();
|
||||||
}
|
}
|
||||||
@@ -531,7 +531,7 @@ void AudioSettingsPage::render(SDL_Renderer* renderer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string devName = (currentDeviceIndex_ >= 0 && currentDeviceIndex_ < static_cast<int>(devices_.size()))
|
std::string devName = (currentDeviceIndex_ >= 0 && currentDeviceIndex_ < static_cast<int>(devices_.size()))
|
||||||
? devices_[currentDeviceIndex_].friendly_name : "Built-In Speakers";
|
? devices_[currentDeviceIndex_].friendly_name : "System Default";
|
||||||
deviceText_.ensure(renderer, fontHeader_, "Device: " + devName, white);
|
deviceText_.ensure(renderer, fontHeader_, "Device: " + devName, white);
|
||||||
if (deviceText_.texture) {
|
if (deviceText_.texture) {
|
||||||
SDL_FRect dst{500.0f, currentY, deviceText_.width, deviceText_.height};
|
SDL_FRect dst{500.0f, currentY, deviceText_.width, deviceText_.height};
|
||||||
|
|||||||
@@ -180,6 +180,16 @@ void Home::render(SDL_Renderer *renderer)
|
|||||||
SDL_RenderFillRect(renderer, &line);
|
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:
|
// Bottom-right status footer (20pt, aligned to a virtual rectangle:
|
||||||
// names left-aligned at the rect's left edge, values right-aligned at
|
// 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
|
// 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);
|
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");
|
SDL_Surface *bgSurface = IMG_Load("assets/images/RWT-Circular-Color-ForOnDark-NoText.png");
|
||||||
if (bgSurface)
|
if (bgSurface)
|
||||||
{
|
{
|
||||||
@@ -410,6 +429,11 @@ void Home::destroyTextures()
|
|||||||
SDL_DestroyTexture(settings_.texture);
|
SDL_DestroyTexture(settings_.texture);
|
||||||
settings_.texture = nullptr;
|
settings_.texture = nullptr;
|
||||||
}
|
}
|
||||||
|
if (wifiNote_.texture)
|
||||||
|
{
|
||||||
|
SDL_DestroyTexture(wifiNote_.texture);
|
||||||
|
wifiNote_.texture = nullptr;
|
||||||
|
}
|
||||||
for (auto &t : footerNames_)
|
for (auto &t : footerNames_)
|
||||||
t.destroy();
|
t.destroy();
|
||||||
for (auto &t : footerValues_)
|
for (auto &t : footerValues_)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ private:
|
|||||||
TextLine play_;
|
TextLine play_;
|
||||||
TextLine settings_;
|
TextLine settings_;
|
||||||
TextLine bg_;
|
TextLine bg_;
|
||||||
|
TextLine wifiNote_;
|
||||||
|
|
||||||
AppState* state_ = nullptr;
|
AppState* state_ = nullptr;
|
||||||
float playY_ = 0;
|
float playY_ = 0;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ TEST(AudioTest, FriendlyDeviceNameMapping) {
|
|||||||
EXPECT_EQ(mapToFriendlyDeviceName("AMD Radeon HD Audio Controller HDMI"), "TV / HDMI Monitor");
|
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("USB Audio Headset Dongle"), "USB Audio Device");
|
||||||
EXPECT_EQ(mapToFriendlyDeviceName("bluez_sink.00_11_22_33_44_55"), "Bluetooth Audio");
|
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");
|
EXPECT_EQ(mapToFriendlyDeviceName(""), "Default Audio Output");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ TEST(AudioTest, GetPlaybackDevicesFallsBackWithIdZeroWhenNone) {
|
|||||||
for (const auto& d : devices) {
|
for (const auto& d : devices) {
|
||||||
if (d.raw_name.empty()) {
|
if (d.raw_name.empty()) {
|
||||||
EXPECT_EQ(d.id, 0u);
|
EXPECT_EQ(d.id, 0u);
|
||||||
|
EXPECT_EQ(d.friendly_name, "System Default");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,3 +61,14 @@ TEST(AudioTest, AudioManagerVolumeClamping) {
|
|||||||
am.shutdown();
|
am.shutdown();
|
||||||
std::filesystem::remove(temp_path);
|
std::filesystem::remove(temp_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(AudioTest, PlayOptionsLoopInfinite) {
|
||||||
|
// Regression: MIX_PlayTrack() must not default to a single pass. Tracks
|
||||||
|
// have to loop forever so playback never runs out mid-scroll; a track that
|
||||||
|
// stops (rather than attenuates) also can't be revived by raising app
|
||||||
|
// volume. Assert the options request infinite looping (-1).
|
||||||
|
SDL_PropertiesID props = AudioManager::createInfiniteLoopOptions();
|
||||||
|
ASSERT_NE(props, 0u);
|
||||||
|
EXPECT_EQ(SDL_GetNumberProperty(props, MIX_PROP_PLAY_LOOPS_NUMBER, 0), -1);
|
||||||
|
SDL_DestroyProperties(props);
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,62 @@ TEST(ScrollerStreamsTest, InterleavesSelectedWithDefault)
|
|||||||
EXPECT_EQ(streams[3].audioFilename, "OriginalAudio.wav");
|
EXPECT_EQ(streams[3].audioFilename, "OriginalAudio.wav");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(ScrollerStreamsTest, InterleavedListCyclesBackToFirstSelected)
|
||||||
|
{
|
||||||
|
// Three selected proclamations: the built list must be
|
||||||
|
// [S1, Default, S2, Default, S3, Default]. ScrollerPage advances through
|
||||||
|
// this list with a modulo, so once the trailing default finishes the cycle
|
||||||
|
// restarts at the FIRST selected proclamation — the "always on" default
|
||||||
|
// must never accumulate or take over the queue.
|
||||||
|
ProclamationRecord b;
|
||||||
|
b.id = "B";
|
||||||
|
b.title = "B";
|
||||||
|
b.content = "bbbb";
|
||||||
|
b.selected = true;
|
||||||
|
b.solfeggio_frequency_id = 2;
|
||||||
|
|
||||||
|
ProclamationRecord c;
|
||||||
|
c.id = "C";
|
||||||
|
c.title = "C";
|
||||||
|
c.content = "cccc";
|
||||||
|
c.selected = true;
|
||||||
|
c.solfeggio_frequency_id = 3;
|
||||||
|
|
||||||
|
ProclamationRecord d;
|
||||||
|
d.id = "D";
|
||||||
|
d.title = "D";
|
||||||
|
d.content = "dddd";
|
||||||
|
d.selected = true;
|
||||||
|
d.solfeggio_frequency_id = 4;
|
||||||
|
|
||||||
|
ProclamationRecord def;
|
||||||
|
def.id = kDefaultId;
|
||||||
|
def.title = "Default";
|
||||||
|
def.content = "dddddddddd";
|
||||||
|
def.selected = true;
|
||||||
|
def.solfeggio_frequency_id = 5;
|
||||||
|
|
||||||
|
auto streams = buildStreams({b, def, c, d}, {}, palette(), {}, kDefaultId);
|
||||||
|
|
||||||
|
ASSERT_EQ(streams.size(), 6u);
|
||||||
|
EXPECT_EQ(streams[0].name, "B");
|
||||||
|
EXPECT_EQ(streams[1].name, "Default");
|
||||||
|
EXPECT_EQ(streams[2].name, "C");
|
||||||
|
EXPECT_EQ(streams[3].name, "Default");
|
||||||
|
EXPECT_EQ(streams[4].name, "D");
|
||||||
|
EXPECT_EQ(streams[5].name, "Default");
|
||||||
|
|
||||||
|
// Every default copy is independent playback state (fresh counters), so a
|
||||||
|
// copy finishing its passes advances to the next stream rather than
|
||||||
|
// restarting "the default" forever.
|
||||||
|
for (size_t i = 1; i < streams.size(); i += 2) {
|
||||||
|
EXPECT_EQ(streams[i].name, "Default");
|
||||||
|
EXPECT_EQ(streams[i].completed, 0) << "default copy " << i;
|
||||||
|
EXPECT_EQ(streams[i].wordsPlaced, 0) << "default copy " << i;
|
||||||
|
EXPECT_EQ(streams[i].repeat, kDefaultRepeatCount) << "default copy " << i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TEST(ScrollerStreamsTest, DefaultOnlyWhenNothingSelected)
|
TEST(ScrollerStreamsTest, DefaultOnlyWhenNothingSelected)
|
||||||
{
|
{
|
||||||
ProclamationRecord p1;
|
ProclamationRecord p1;
|
||||||
|
|||||||
Reference in New Issue
Block a user