8 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
NeilClack 2917d33866 fixed (bug) Audio not looping after ending.
build-dev / nix-build (push) Successful in 46s
2026-08-28 13:55:20 -05:00
NeilClack 3f6201182e Publish live-update manifest to fileserver (dev)
build-dev / nix-build (push) Successful in 43s
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.
2026-08-27 20:58:13 -05:00
11 changed files with 330 additions and 49 deletions
+14
View File
@@ -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
+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
'';
+169 -43
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;
@@ -157,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<std::mutex> lock(mutex_);
@@ -213,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;
@@ -435,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.
@@ -446,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;
@@ -462,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);
@@ -495,7 +599,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 +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)");
}
@@ -567,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
@@ -604,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;
}
@@ -642,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<Sint64>(fade * 1000.0)));
active.fadeOutRemaining = fade;
}
@@ -673,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<Sint64>(fade * 1000.0)));
slot.fadeOutRemaining = fade;
faded = true;
@@ -707,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)
{
@@ -724,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);
}
}
}
+16 -2
View File
@@ -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,11 +103,19 @@ 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. 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;
+13
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");
}
}
}
@@ -59,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);
}
+56
View File
@@ -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;