4 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
6 changed files with 93 additions and 24 deletions
+15 -1
View File
@@ -2,7 +2,7 @@ name: build-main
on: on:
# Manual only. Dispatch against main: build, run the offline test suite, # 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: workflow_dispatch:
jobs: jobs:
@@ -74,3 +74,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 ./result-pkg 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
+47 -20
View File
@@ -512,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.
@@ -523,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;
@@ -539,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);
+5 -2
View File
@@ -111,8 +111,11 @@ private:
// 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;
+1 -1
View File
@@ -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();
} }
+24
View File
@@ -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, &noteDst);
}
// 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_)
+1
View File
@@ -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;