diff --git a/wled00/const.h b/wled00/const.h index 70373316fd..6edf333218 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -448,6 +448,8 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); //Playlist option byte #define PL_OPTION_SHUFFLE 0x01 #define PL_OPTION_RESTORE 0x02 +#define PL_OPTION_DETERMINISTIC_SHUFFLE 0x04 +#define PL_OPTION_CLOCK_SYNC 0x08 // Segment capability byte #define SEG_CAPABILITY_RGB 0x01 diff --git a/wled00/data/index.js b/wled00/data/index.js index ee5126973c..4b4f3b4c2c 100644 --- a/wled00/data/index.js +++ b/wled00/data/index.js @@ -2004,10 +2004,46 @@ function pleTr(p,i,field) plJson[p].transition[i] = Math.floor(field.value*10); } -function plR(p) +function plUpdateOptions(p, changed) { var pl = plJson[p]; - pl.r = gId(`pl${p}rtgl`).checked; + + const shuffle = gId(`pl${p}rtgl`).checked; + const deterministic = gId(`pl${p}deterministic`); + const clockSync = gId(`pl${p}clockSync`); + const manual = gId(`pl${p}manual`); + let manualChanged = changed == "manual"; + + // Ensure clock sync and manual advance can't be checked at the same time + if (clockSync.checked && manual.checked) { + if (changed == "clockSync") { + manual.checked = false; + manualChanged = true; + } else { + clockSync.checked = false; + } + } + + // Reset & hide deterministic flag when not shuffling + if (!shuffle) deterministic.checked = false; + gId(`pl${p}deterministicRow`).style.display = shuffle ? "block" : "none"; + + pl.r = shuffle; + pl.deterministic = deterministic.checked; + pl.clockSync = clockSync.checked; + + // Normalize durations when manual advance changes + if (manualChanged) { + plJson[p].dur.forEach((_,i)=>{ + const e = manual.checked ? 0 : 100; + const d = gId(`pl${p}du${i}`); + plJson[p].dur[i] = e; + d.value = e/10; // 10s default + d.readOnly = manual.checked; + }); + } + + // Update repeat/end-preset controls if (gId(`pl${p}rptgl`).checked) { // infinite pl.repeat = 0; delete pl.end; @@ -2019,17 +2055,6 @@ function plR(p) } } -function plM(p) -{ - const man = gId(`pl${p}manual`).checked; - plJson[p].dur.forEach((e,i)=>{ - const d = gId(`pl${p}du${i}`); - plJson[p].dur[i] = e = man ? 0 : 100; - d.value = e/10; // 10s default - d.readOnly = man; - }); -} - function makeP(i,pl) { var content = ""; @@ -2041,27 +2066,39 @@ function makeP(i,pl) transition: [tr], repeat: 0, r: false, + deterministic: false, + clockSync: false, end: 0 }; + const clockSync = !!plJson[i].clockSync; + if (!plJson[i].r) plJson[i].deterministic = false; const rep = plJson[i].repeat ? plJson[i].repeat : 0; - const man = plJson[i].dur == 0; + const man = !clockSync && plJson[i].dur.every(d => d == 0); content = `
+ +
-
Repeat 0?rep:1}> times
+
Repeat 0?rep:1}> times
End preset:
-
${makePlSel(i, plJson[i].end?plJson[i].end:0)} @@ -2124,6 +2161,7 @@ function makePUtil() p.classList.remove('staybot'); p.classList.add('pres'); p.innerHTML = `
${makeP(0)}
`; + tooltip("#putil"); let pTx = gId('p0txt'); pTx.focus(); pTx.value = eJson.find((o)=>{return o.id==selectedFx}).name; @@ -2173,6 +2211,7 @@ function makePlUtil() p.classList.add('pres'); p.innerHTML = `
${makeP(0,true)}
`; refreshPlE(0); + tooltip("#putil"); gId('p0txt').focus(); p.scrollIntoView({ behavior: 'smooth', @@ -3050,12 +3089,15 @@ function expand(i) formatArr(plJson[p]); if (isNaN(plJson[p].repeat)) plJson[p].repeat = 0; if (!plJson[p].r) plJson[p].r = false; + if (!plJson[p].deterministic) plJson[p].deterministic = false; + if (!plJson[p].clockSync) plJson[p].clockSync = false; if (isNaN(plJson[p].end)) plJson[p].end = 0; gId('seg' +i).innerHTML = makeP(p,true); refreshPlE(p); } else { gId('seg' +i).innerHTML = makeP(p); } + tooltip('#seg' + i); var papi = papiVal(p); gId(`p${p}api`).value = papi; if (papi.indexOf("Please") == 0) gId(`p${p}cstgl`).checked = false; diff --git a/wled00/fcn_declare.h b/wled00/fcn_declare.h index 6201a19192..884e3896f1 100644 --- a/wled00/fcn_declare.h +++ b/wled00/fcn_declare.h @@ -256,7 +256,6 @@ void handleOverlayDraw(); // void _overlayAnalogClock(); // local function, only used in overlay.cpp //playlist.cpp -void shufflePlaylist(); void unloadPlaylist(); int16_t loadPlaylist(JsonObject playlistObject, byte presetId = 0); void handlePlaylist(); diff --git a/wled00/playlist.cpp b/wled00/playlist.cpp index c5acecdaf1..5eb73a1bed 100644 --- a/wled00/playlist.cpp +++ b/wled00/playlist.cpp @@ -1,4 +1,5 @@ #include "wled.h" +#include "prng.h" /* * Handles playlists, timed sequences of presets @@ -12,35 +13,66 @@ typedef struct PlaylistEntry { static byte playlistRepeat = 1; //how many times to repeat the playlist (0 = infinitely) static byte playlistEndPreset = 0; //what preset to apply after playlist end (0 = stay on last preset) -static byte playlistOptions = 0; //bit 0: shuffle playlist after each iteration. bits 1-7 TBD +static byte playlistOptions = 0; //PL_OPTION_* static PlaylistEntry *playlistEntries = nullptr; static byte playlistLen; //number of playlist entries static int8_t playlistIndex = -1; +static uint64_t clockSyncEntryStart = UINT64_MAX; //cycle time where playlistIndex starts static uint32_t playlistEntryDur = 0; //duration of the current entry in milliseconds +static uint64_t playlistTotalDur = 0; //sum of all entry durations in milliseconds +static uint32_t playlistCycleNum = 0; //current playlist cycle for deterministic shuffle +static PRNG prng; +static bool playlistEntriesAreShuffled = false; //values we need to keep about the parent playlist while inside sub-playlist static int16_t parentPlaylistIndex = -1; static byte parentPlaylistRepeat = 0; static byte parentPlaylistPresetId = 0; //for re-loading +static uint16_t deterministicShuffleSeed() { + uint32_t seed = 2166136261UL; + seed = (seed ^ playlistCycleNum) * 16777619UL; + seed = (seed ^ playlistLen) * 16777619UL; + if (seed == 0) seed = 2166136261UL; + return (uint16_t)((seed >> 16) ^ seed); +} -void shufflePlaylist() { - int currentIndex = playlistLen; - PlaylistEntry temporaryValue; +static void unShufflePlaylist() { + if (playlistEntries == nullptr || playlistLen < 2 || !playlistEntriesAreShuffled || !(playlistOptions & PL_OPTION_DETERMINISTIC_SHUFFLE)) return; - // While there remain elements to shuffle... - while (currentIndex--) { - // Pick a random element... - int randomIndex = random(0, currentIndex); - // And swap it with the current element. - temporaryValue = playlistEntries[currentIndex]; + PlaylistEntry temp; + for (int currentIndex = 1; currentIndex < playlistLen; currentIndex++) { + int randomIndex = prng.random16Backwards(currentIndex + 1); + temp = playlistEntries[currentIndex]; playlistEntries[currentIndex] = playlistEntries[randomIndex]; - playlistEntries[randomIndex] = temporaryValue; + playlistEntries[randomIndex] = temp; } - DEBUG_PRINTLN(F("Playlist shuffle.")); + playlistEntriesAreShuffled = false; + clockSyncEntryStart = UINT64_MAX; + DEBUG_PRINTLN(F("Playlist unshuffled.")); } +static void shufflePlaylist() { + if (playlistEntries == nullptr || playlistLen < 2) return; + + bool deterministic = playlistOptions & PL_OPTION_DETERMINISTIC_SHUFFLE; + if (deterministic) { + unShufflePlaylist(); + prng.setSeed(deterministicShuffleSeed()); + } + + PlaylistEntry temp; + for (int currentIndex = playlistLen - 1; currentIndex > 0; currentIndex--) { + int randomIndex = deterministic ? prng.random16(currentIndex + 1) : random(0, currentIndex + 1); + temp = playlistEntries[currentIndex]; + playlistEntries[currentIndex] = playlistEntries[randomIndex]; + playlistEntries[randomIndex] = temp; + } + playlistEntriesAreShuffled = true; + clockSyncEntryStart = UINT64_MAX; + DEBUG_PRINTLN(F("Playlist shuffled.")); +} void unloadPlaylist() { if (playlistEntries != nullptr) { @@ -51,9 +83,52 @@ void unloadPlaylist() { playlistLen = 0; playlistOptions = 0; playlistEntryDur = 0; + playlistTotalDur = 0; + playlistCycleNum = 0; + playlistEntriesAreShuffled = false; + clockSyncEntryStart = UINT64_MAX; DEBUG_PRINTLN(F("Playlist unloaded.")); } +// Convert absolute Toki time into a playlist cycle number and elapsed time within that cycle +static bool getClockSyncPlaylistCycle(uint32_t &cycleNum, uint64_t &cycleTime) { + if (!(playlistOptions & PL_OPTION_CLOCK_SYNC) || playlistTotalDur == 0) return false; + if (toki.getTimeSource() == TOKI_TS_NONE) return false; + + Toki::Time tm = toki.getTime(); + uint64_t absoluteMs = (uint64_t)tm.sec * 1000ULL + tm.ms; + cycleNum = (uint32_t)(absoluteMs / playlistTotalDur); + cycleTime = absoluteMs % playlistTotalDur; + return true; +} + +// Map elapsed time inside the current wall-clock cycleNum to a slot in playlistEntries +// and the elapsed time inside that slot +static bool mapCycleTimeToPlaylistSlot(uint64_t cycleTime, int8_t &entrySlot, uint32_t &entryOffset) { + if (playlistLen == 0 || playlistEntries == nullptr) return false; + + byte startIndex = 0; + uint64_t entryStart = 0; + if (clockSyncEntryStart < playlistTotalDur && playlistIndex >= 0 && playlistIndex < playlistLen && cycleTime >= clockSyncEntryStart) { + startIndex = playlistIndex; + entryStart = clockSyncEntryStart; + cycleTime -= clockSyncEntryStart; + } + + for (byte i = startIndex; i < playlistLen; i++) { + uint32_t dur = playlistEntries[i].dur; + if (cycleTime < dur) { + entrySlot = i; + entryOffset = (uint32_t)cycleTime; + clockSyncEntryStart = entryStart; + return true; + } + cycleTime -= dur; + entryStart += dur; + } + + return false; +} int16_t loadPlaylist(JsonObject playlistObj, byte presetId) { if (currentPlaylist > 0 && parentPlaylistPresetId > 0) return -1; // we are already in nested playlist, do nothing @@ -78,6 +153,7 @@ int16_t loadPlaylist(JsonObject playlistObj, byte presetId) { playlistEntries[it].preset = ps; it++; } + playlistEntriesAreShuffled = false; it = 0; JsonArray durations = playlistObj["dur"]; @@ -96,6 +172,8 @@ int16_t loadPlaylist(JsonObject playlistObj, byte presetId) { } if (it > 0) // should never happen but just in case for (int i = it; i < playlistLen; i++) playlistEntries[i].dur = playlistEntries[it -1].dur; + playlistTotalDur = 0; + for (int i = 0; i < playlistLen; i++) playlistTotalDur += playlistEntries[i].dur; it = 0; JsonArray tr = playlistObj[F("transition")]; @@ -126,9 +204,15 @@ int16_t loadPlaylist(JsonObject playlistObj, byte presetId) { playlistOptions |= PL_OPTION_RESTORE; // for async save operation } if (playlistEndPreset > 250) playlistEndPreset = 0; - shuffle = shuffle || playlistObj["r"]; + shuffle = shuffle || playlistObj["r"].as(); if (shuffle) playlistOptions |= PL_OPTION_SHUFFLE; + bool clockSync = playlistObj[F("clockSync")].as(); + if (clockSync) playlistOptions |= PL_OPTION_CLOCK_SYNC; + + bool deterministicShuffle = playlistObj[F("deterministic")].as(); + if (shuffle && deterministicShuffle) playlistOptions |= PL_OPTION_DETERMINISTIC_SHUFFLE; + if (parentPlaylistPresetId == 0 && parentPlaylistIndex > -1) { // we are re-loading playlist when returning from nested playlist playlistIndex = parentPlaylistIndex; @@ -152,31 +236,77 @@ void handlePlaylist() { static unsigned long presetCycledTime = 0; if (currentPlaylist < 0 || playlistEntries == nullptr) return; - if ((playlistEntryDur < UINT32_MAX && millis() - presetCycledTime > playlistEntryDur) || doAdvancePlaylist) { - presetCycledTime = millis(); - if (bri == 0 || nightlightActive) return; - - ++playlistIndex %= playlistLen; // -1 at 1st run (limit to playlistLen) - - // playlist roll-over - if (!playlistIndex) { - if (playlistRepeat == 1) { //stop if all repetitions are done - unloadPlaylist(); - if (parentPlaylistPresetId > 0) { - applyPresetFromPlaylist(parentPlaylistPresetId); // reload previous playlist (unfortunately asynchronous) - parentPlaylistPresetId = 0; // reset previous playlist but do not reset Index or Repeat (they will be loaded & reset in loadPlaylist()) - } else if (playlistEndPreset) applyPresetFromPlaylist(playlistEndPreset); - return; - } - if (playlistRepeat > 1) playlistRepeat--; // decrease repeat count on each index reset if not an endless playlist - // playlistRepeat == 0: endless loop - if (playlistOptions & PL_OPTION_SHUFFLE) shufflePlaylist(); // shuffle playlist and start over + unsigned long now = millis(); + uint64_t clockSyncCycleTime = 0; + uint32_t targetCycleNum = playlistCycleNum; + int8_t targetPlaylistIndex = playlistIndex; + bool clockSyncTimeValid = false; + bool needsRollover = false; + bool shouldApplyEntry = false; + + // Derive wall-clock rollover state + if (playlistOptions & PL_OPTION_CLOCK_SYNC) { + uint32_t cycleNum = 0; + if (getClockSyncPlaylistCycle(cycleNum, clockSyncCycleTime)) { + clockSyncTimeValid = true; + targetCycleNum = cycleNum; + if (playlistIndex < 0 || playlistCycleNum != targetCycleNum) needsRollover = true; + // Clock-synced playlists derive the active slot from wall time, so manual "next preset" cannot persist. + doAdvancePlaylist = false; + } + } + + // Derive local-clock/manual advance state + if (!clockSyncTimeValid && ((playlistEntryDur < UINT32_MAX && now - presetCycledTime > playlistEntryDur) || doAdvancePlaylist)) { + presetCycledTime = now; + + targetPlaylistIndex = (playlistIndex + 1) % playlistLen; // -1 at 1st run (limit to playlistLen) + + if (!targetPlaylistIndex) { + if (playlistIndex >= 0) targetCycleNum++; + needsRollover = true; + } + + shouldApplyEntry = true; + } + + if (bri == 0 || nightlightActive) return; + + // Apply rollover + if (needsRollover) { + playlistCycleNum = targetCycleNum; + if (playlistRepeat == 1) { //stop if all repetitions are done + unloadPlaylist(); + if (parentPlaylistPresetId > 0) { + applyPresetFromPlaylist(parentPlaylistPresetId); // reload previous playlist (unfortunately asynchronous) + parentPlaylistPresetId = 0; // reset previous playlist but do not reset Index or Repeat (they will be loaded & reset in loadPlaylist()) + } else if (playlistEndPreset) applyPresetFromPlaylist(playlistEndPreset); + return; } + if (playlistRepeat > 1) playlistRepeat--; // decrease repeat count on each index reset if not an endless playlist + // playlistRepeat == 0: endless loop + if (playlistOptions & PL_OPTION_SHUFFLE) shufflePlaylist(); // shuffle playlist and start over + } + + // Map wall-clock time to the active playlist slot + if (clockSyncTimeValid) { + uint32_t clockSyncEntryOffset = 0; + if (mapCycleTimeToPlaylistSlot(clockSyncCycleTime, targetPlaylistIndex, clockSyncEntryOffset)) { + presetCycledTime = now - clockSyncEntryOffset; + shouldApplyEntry = needsRollover || playlistIndex != targetPlaylistIndex; + strip.timebase = (unsigned long)clockSyncEntryOffset - now; + } + } + // Apply the selected playlist entry + if (shouldApplyEntry) { + playlistIndex = targetPlaylistIndex; + if (!clockSyncTimeValid) clockSyncEntryStart = UINT64_MAX; jsonTransitionOnce = true; - strip.setTransition(playlistEntries[playlistIndex].tr * 100); - playlistEntryDur = playlistEntries[playlistIndex].dur > 0 ? playlistEntries[playlistIndex].dur : UINT32_MAX; // UINT32_MAX means infinite - applyPresetFromPlaylist(playlistEntries[playlistIndex].preset); + PlaylistEntry &entry = playlistEntries[playlistIndex]; + strip.setTransition(entry.tr * 100); + playlistEntryDur = entry.dur > 0 ? entry.dur : UINT32_MAX; // UINT32_MAX means infinite + applyPresetFromPlaylist(entry.preset); doAdvancePlaylist = false; } } @@ -189,7 +319,9 @@ void serializePlaylist(JsonObject sObj) { JsonArray transition = playlist.createNestedArray(F("transition")); playlist[F("repeat")] = (playlistIndex < 0 && playlistRepeat > 0) ? playlistRepeat - 1 : playlistRepeat; // remove added repetition count (if not yet running) playlist["end"] = playlistOptions & PL_OPTION_RESTORE ? 255 : playlistEndPreset; - playlist["r"] = playlistOptions & PL_OPTION_SHUFFLE; + playlist["r"] = (playlistOptions & PL_OPTION_SHUFFLE) != 0; + playlist[F("deterministic")] = (playlistOptions & PL_OPTION_DETERMINISTIC_SHUFFLE) != 0; + playlist[F("clockSync")] = (playlistOptions & PL_OPTION_CLOCK_SYNC) != 0; for (int i=0; i> 8); } uint8_t random8(uint8_t min, uint8_t lim) { uint8_t delta = lim - min; return random8(delta) + min; } + + uint16_t random16Backwards() { + uint16_t x = seed; + seed ^= seed >> 7; + seed ^= seed >> 14; + seed = (seed - 31683) * 35465; + return x; + } + uint16_t random16Backwards(uint16_t lim) { return ((uint32_t)random16Backwards() * lim) >> 16; } + uint16_t random16Backwards(uint16_t min, uint16_t lim) { uint16_t delta = lim - min; return random16Backwards(delta) + min; } + uint8_t random8Backwards() { return random16Backwards(); } + uint8_t random8Backwards(uint8_t lim) { return (uint8_t)(((uint16_t)random8Backwards() * lim) >> 8); } + uint8_t random8Backwards(uint8_t min, uint8_t lim) { uint8_t delta = lim - min; return random8Backwards(delta) + min; } };