6 Commits
Author SHA1 Message Date
NeilClack fee31c87b8 Add manifest upload to release workflow 2026-08-28 16:51:53 -05:00
NeilClack 5369532ab4 Merge pull request 'Fix audio device problems' (#1) from dev into main
Reviewed-on: #1
2026-08-28 21:30:12 +00:00
NeilClack 42b915bd25 fixed (bug) Audio device switch killing playback via stale SDL device id
build-dev / nix-build (push) Successful in 45s
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.
2026-08-28 16:18:36 -05:00
NeilClack d60a8bd7fb Add WiFi prompt
build-dev / nix-build (push) Successful in 46s
2026-08-28 15:35:57 -05:00
NeilClack ca7b8b7e6f Update audio pipeline with extensive logging and defualt with fallbacks
build-dev / nix-build (push) Successful in 45s
2026-08-28 15:04:42 -05:00
NeilClack bf2d4d1f89 Update build dependencies to properly link dynamically. 2026-08-28 15:03:58 -05:00
9 changed files with 160 additions and 27 deletions
+15 -1
View File
@@ -2,7 +2,7 @@ name: build-main
on:
# Manual only. Dispatch against main: build, run the offline test suite,
# publish to the release Attic cache.
# publish to the release Attic cache and live-update manifest.
workflow_dispatch:
jobs:
@@ -74,3 +74,17 @@ jobs:
run: |
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave ./result-pkg
# Publish the manifest production kiosks poll for live app updates.
# `storePath` must match exactly the closure pushed to the release 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/manifest.json
+8 -1
View File
@@ -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
+12
View File
@@ -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
'';
+91 -21
View File
@@ -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;
@@ -469,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.
@@ -480,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;
@@ -496,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);
+5 -2
View File
@@ -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;
+2 -2
View File
@@ -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();
}
@@ -531,7 +531,7 @@ void AudioSettingsPage::render(SDL_Renderer* renderer) {
}
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);
if (deviceText_.texture) {
SDL_FRect dst{500.0f, currentY, deviceText_.width, deviceText_.height};
+24
View File
@@ -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, &noteDst);
}
// 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_)
+1
View File
@@ -34,6 +34,7 @@ private:
TextLine play_;
TextLine settings_;
TextLine bg_;
TextLine wifiNote_;
AppState* state_ = nullptr;
float playY_ = 0;
+2
View File
@@ -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");
}
}
}