diff --git a/wled00/const.h b/wled00/const.h index 6ee8345182..73e1a5e4a3 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -191,6 +191,7 @@ #define REALTIME_MODE_ARTNET 6 #define REALTIME_MODE_TPM2NET 7 #define REALTIME_MODE_DDP 8 +#define REALTIME_MODE_FSEQ 10 //used 10 instead of 9 to keep compatibility with TPM2RECORD once merged //realtime override modes #define REALTIME_OVERRIDE_NONE 0 diff --git a/wled00/data/edit.htm b/wled00/data/edit.htm index 4f06642331..c6c450ce08 100644 --- a/wled00/data/edit.htm +++ b/wled00/data/edit.htm @@ -483,6 +483,7 @@ case "json": case "xml": case "ini": + case "fseq": lang = ext; } } diff --git a/wled00/fseqrecord.cpp b/wled00/fseqrecord.cpp new file mode 100644 index 0000000000..5b4db35399 --- /dev/null +++ b/wled00/fseqrecord.cpp @@ -0,0 +1,321 @@ +#include "fseqrecord.h" + +// This adds FSEQ-file storing and playback capabilities to WLED. +// +// >> Credit goes to @constant-flow for the original idea and structure created for TPM2 playback! << +// https://github.com/Aircoookie/WLED/pull/2292 +// +// What does it mean: +// You can now store short recorded animations on the ESP32 (in the ROM: no SD required) with a connected LED stripe. +// +// How to transfer the animation: +// WLED offers a web file manager under /edit here you can upload a recorded *.FSEQ file +// +// How to create a recording: +// You can record with tools like xLights or Vixen +// +// How to load the animation: +// You can specify a preset to playback this recording with the following API command +// {"fseq":{"file":"/record.fseq"}} +// +// You can specify a preset to playback this recording on a specific segment +// {"fseq":{"file":"/record.fseq", "seg":{"id":2}} +// {"fseq":{"file":"/record.fseq", "seg":2} +// +// How to trigger the animation: +// Presets can be triggered multiple interfaces e.g. via the json API, via the web interface or with a connected IR remote +// +// How to configure SD card: +// Arduino only supports up to SDHC 32gb, and the card must be formatted using FAT32. To optimize read efficiency, pixel +// data is parsed in "chunks". This may need to be adjusted if the default "chunk" size is either too large (out of memory), +// or is too small (too slow). +// +// Most devices will work with the SD_MCC library, however some devices such as LilyGO / TTGO may require the SPI interface +// to be used instead. This can be configured by using the WLED_USE_SD_SPI parameter. SPI PIN configuration will use the +// default PINs as defined in Arduino, but can be overridden if needed using WLED_PIN_(SCK|MISO|MOSI|SS) defines. +// +// For example, TTGO T8 can be configured in platformio(_override).ini as follows. +// -D WLED_USE_SD_SPI +// -D WLED_PIN_SCK=14 +// -D WLED_PIN_MISO=2 +// -D WLED_PIN_MOSI=15 +// -D WLED_PIN_SS=13 +// +// What next: +// - Add support for compressed FSEQ files, not explored yet. +// - Add support for "sparse ranges", did not seem to be used by xLights. +// - Add support for complete file header, including variable length header, however not required for any scenario yet + +// reference spec of FSEQ: https://github.com/Cryptkeeper/fseq-file-format +// first-party FPP file format guide: https://github.com/FalconChristmas/fpp/blob/master/docs/FSEQ_Sequence_File_Format.txt + +// --- Recording playback related --- +File FSEQFile::recordingFile; +uint8_t FSEQFile::colorChannels = 3; +int32_t FSEQFile::recordingRepeats = RECORDING_REPEAT_LOOP; +uint32_t FSEQFile::now = 0; +uint32_t FSEQFile::next_time = 0; +uint16_t FSEQFile::playbackLedStart = 0; // first led to play animation on +uint16_t FSEQFile::playbackLedStop = 0; // led after the last led to play animation on +uint32_t FSEQFile::frame = 0; // current frame +uint16_t FSEQFile::buffer_size = 48; // data buffer size for file read operations (1 byte buffer == ~4 fps, 3 byte buffer == ~20 fps, etc...) +FSEQFile::file_header_t FSEQFile::file_header; + +// --- File reading functions --- +inline uint32_t FSEQFile::readUInt32() { + uint32_t len = 4; + char buffer[len]; + if (recordingFile.readBytes(buffer, len) < len) return 0; + uint32_t u32 = (((uint32_t)buffer[0]) + (((uint32_t)buffer[1]) << 8) + + (((uint32_t)buffer[2]) << 16) + (((uint32_t)buffer[3]) << 24)); + return u32; +} +inline uint32_t FSEQFile::readUInt24() { + uint32_t len = 3; + char buffer[len]; + if (recordingFile.readBytes(buffer, len) < len) return 0; + uint32_t u24 = + (((uint32_t)buffer[0]) + (((uint32_t)buffer[1]) << 8) + (((uint32_t)buffer[2]) << 16)); + return u24; +} +inline uint16_t FSEQFile::readUInt16() { + uint32_t len = 2; + char buffer[len]; + if (recordingFile.readBytes(buffer, len) < len) return 0; + uint16_t u16 = (((uint16_t)buffer[0]) + (((uint16_t)buffer[1]) << 8)); + return u16; +} +inline uint8_t FSEQFile::readUInt8() { + uint32_t len = 1; + char buffer[len]; + if (recordingFile.readBytes(buffer, len) < len) return 0; + uint8_t u8 = (((uint8_t)buffer[0])); + return u8; +} + +bool FSEQFile::fileOnSD(const char* filepath) +{ +#if defined(WLED_USE_SD) || defined(WLED_USE_SD_SPI) +#ifdef WLED_USE_SD_SPI + SPI.begin(WLED_PIN_SCK, WLED_PIN_MISO, WLED_PIN_MOSI, WLED_PIN_SS); + if (!WLED_SD.begin(WLED_PIN_SS)) return false; +#else + if (!WLED_SD.begin("/sdcard", true)) return false; // mounting the card failed +#endif + + uint8_t cardType = WLED_SD.cardType(); + if (cardType == CARD_NONE) return false; // no SD card attached + if (cardType == CARD_MMC || cardType == CARD_SD || cardType == CARD_SDHC) + { + return WLED_SD.exists(filepath); + } +#endif + + return false; // unknown card type +} +bool FSEQFile::fileOnFS(const char* filepath) +{ + return WLED_FS.exists(filepath); +} + +// --- Common functions --- +void FSEQFile::handlePlayRecording() +{ + now = millis(); + if (realtimeMode != REALTIME_MODE_FSEQ) return; + if (now < next_time) return; + + playNextRecordingFrame(); +} +void FSEQFile::loadRecording(const char* filepath, uint16_t startLed, uint16_t stopLed) +{ + // close any potentially open file + if (recordingFile.available()) { + clearLastPlayback(); + recordingFile.close(); + } + + playbackLedStart = startLed; + playbackLedStop = stopLed; + + // No start/stop defined + if (playbackLedStart == uint16_t(-1) || playbackLedStop == uint16_t(-1)) { + WS2812FX::Segment sg = strip.getSegment(-1); + playbackLedStart = sg.start; + playbackLedStop = sg.stop; + } + + DEBUG_PRINTF("FSEQ load animation on LED %d to %d\r\n", playbackLedStart, playbackLedStop); + +#if defined(WLED_USE_SD) || defined(WLED_USE_SD_SPI) + if (fileOnSD(filepath)) { + DEBUG_PRINTF("Read file from SD: %s\r\n", filepath); + recordingFile = WLED_SD.open(filepath, "rb"); + } + else +#endif + if (fileOnFS(filepath)) { + DEBUG_PRINTF("Read file from FS: %s\r\n", filepath); + recordingFile = WLED_FS.open(filepath, "rb"); + } + else { + DEBUG_PRINTF("File %s not found (%s)\r\n", filepath, USED_STORAGE_FILESYSTEMS); + return; + } + + // Parse header + if ((uint64_t)recordingFile.available() < sizeof(file_header)) { + DEBUG_PRINTF("Invalid file size: %d\r\n", recordingFile.available()); + recordingFile.close(); + return; + } + for (int i = 0; i < 4; i++) { + file_header.identifier[i] = readUInt8(); + } + file_header.channel_data_offset = readUInt16(); + file_header.minor_version = readUInt8(); + file_header.major_version = readUInt8(); + file_header.header_length = readUInt16(); + file_header.channel_count = readUInt32(); + file_header.frame_count = readUInt32(); + file_header.step_time = readUInt8(); + file_header.flags = readUInt8(); + + // Print debug info + printHeaderInfo(); + + // Verify file format + if (file_header.identifier[0] != 'P' || file_header.identifier[1] != 'S' || file_header.identifier[2] != 'E' || file_header.identifier[3] != 'Q') { + DEBUG_PRINTF("Error reading FSEQ file %s header, invalid identifier\r\n", filepath); + recordingFile.close(); + return; + } + if ((file_header.minor_version != V1FSEQ_MINOR_VERSION && file_header.major_version != V1FSEQ_MAJOR_VERSION) || (file_header.minor_version != V2FSEQ_MINOR_VERSION && file_header.major_version != V2FSEQ_MAJOR_VERSION)) { + DEBUG_PRINTF("Error reading FSEQ file %s header, unknown version 0x%" PRIX8 " 0x%" PRIX8 "\r\n", filepath, file_header.minor_version, file_header.major_version); + recordingFile.close(); + return; + } + if (((uint64_t)file_header.channel_count * (uint64_t)file_header.frame_count) + file_header.header_length > UINT32_MAX) { + DEBUG_PRINTF("Error reading FSEQ file %s header, file too long (max 4gb)\r\n", filepath); + recordingFile.close(); + return; + } + if (file_header.step_time < 1) { + DEBUG_PRINTF("Invalid step time %d, using default %d instead\r\n", file_header.step_time, FSEQ_DEFAULT_STEP_TIME); + file_header.step_time = FSEQ_DEFAULT_STEP_TIME; + } + + if (realtimeOverride == REALTIME_OVERRIDE_ONCE) { + realtimeOverride = REALTIME_OVERRIDE_NONE; + } + + recordingRepeats = RECORDING_REPEAT_DEFAULT; + playNextRecordingFrame(); +} + +void FSEQFile::printHeaderInfo() { + DEBUG_PRINTLN("FSEQ file_header:"); + DEBUG_PRINT(F(" channel_data_offset = ")); DEBUG_PRINTLN(file_header.channel_data_offset); + DEBUG_PRINT(F(" minor_version = ")); DEBUG_PRINTLN(file_header.minor_version); + DEBUG_PRINT(F(" major_version = ")); DEBUG_PRINTLN(file_header.major_version); + DEBUG_PRINT(F(" header_length = ")); DEBUG_PRINTLN(file_header.header_length); + DEBUG_PRINT(F(" channel_count = ")); DEBUG_PRINTLN(file_header.channel_count); + DEBUG_PRINT(F(" frame_count = ")); DEBUG_PRINTLN(file_header.frame_count); + DEBUG_PRINT(F(" step_time = ")); DEBUG_PRINTLN(file_header.step_time); + DEBUG_PRINT(F(" flags = ")); DEBUG_PRINTLN(file_header.flags); +} + +void FSEQFile::processFrameData() +{ + uint16_t packetLength = file_header.channel_count; + uint16_t lastLed = min(playbackLedStop, uint16_t(playbackLedStart + (packetLength / 3))); + + // process data in "chunks" to speed up read operation + char frame_data[buffer_size]; + CRGB* crgb = reinterpret_cast(frame_data); + + uint16_t bytes_remaining = packetLength; + uint16_t index = playbackLedStart; + while (index < lastLed && bytes_remaining > 0) { + uint16_t length = min(bytes_remaining, buffer_size); + recordingFile.readBytes(frame_data, length); + bytes_remaining -= length; + + for (uint16_t offset = 0; offset < length / 3; offset++) { + setRealtimePixel(index, (byte)crgb[offset].r, (byte)crgb[offset].g, (byte)crgb[offset].b, 0); + if (++index > lastLed) break; // end of string or data + } + } + + strip.show(); + + // tell ui we are playing the recording right now + uint8_t mode = REALTIME_MODE_FSEQ; + realtimeLock(realtimeTimeoutMs, mode); + + next_time = now + file_header.step_time; +} + +void FSEQFile::clearLastPlayback() { + + for (uint16_t i = playbackLedStart; i < playbackLedStop; i++) + { + setRealtimePixel(i, 0, 0, 0, 0); + } + + frame = 0; // reset frame index +} + +bool FSEQFile::stopBecauseAtTheEnd() +{ + // if recording reached end loop or stop playback + if (!recordingFile.available()) + { + if (recordingRepeats == RECORDING_REPEAT_LOOP) + { + recordingFile.seek(0); // go back the beginning of the recording + } + else if (recordingRepeats > 0) + { + recordingFile.seek(0); // go back the beginning of the recording + recordingRepeats--; + DEBUG_PRINTF("Repeat recording again for: %" PRId32 "\r\n", recordingRepeats); + } + else + { + DEBUG_PRINTLN(F("Finished playing recording, disabling realtime mode")); + uint8_t mode = REALTIME_MODE_INACTIVE; + realtimeLock(10, mode); + recordingFile.close(); + clearLastPlayback(); + return true; + } + } + + return false; +} + +// scan and forward until next frame was read (this will process commands) +void FSEQFile::playNextRecordingFrame() +{ + if (stopBecauseAtTheEnd()) return; + + // go to next FSEQ frame offset + uint32_t offset = file_header.channel_count; + offset *= frame++; + offset += file_header.header_length; + + if (!recordingFile.seek(offset)) { + // check position, avoid false error when already at correct offset + if (recordingFile.position() != offset) { + DEBUG_PRINTLN(F("Failed to seek to proper offset for channel data!")); + DEBUG_PRINT(F(" offset: ")); DEBUG_PRINTLN(offset); + DEBUG_PRINT(F(" position: ")); DEBUG_PRINTLN(recordingFile.position()); + DEBUG_PRINT(F(" available: ")); DEBUG_PRINTLN(recordingFile.available()); + return; + } + } + + // process everything until the next frame + processFrameData(); +} \ No newline at end of file diff --git a/wled00/fseqrecord.h b/wled00/fseqrecord.h new file mode 100644 index 0000000000..93fa14e50e --- /dev/null +++ b/wled00/fseqrecord.h @@ -0,0 +1,104 @@ +#ifndef FSEQRECORD_H +#define FSEQRECORD_H + +#include "wled.h" + +#if defined(WLED_USE_SD) || defined(WLED_USE_SD_SPI) + #define USED_STORAGE_FILESYSTEMS "SD, LittleFS" + #ifdef WLED_USE_SD_SPI + #ifndef WLED_USE_SD + #define WLED_USE_SD + #endif + + #include + #include + + #ifndef WLED_PIN_SCK + #define WLED_PIN_SCK SCK + #endif + #ifndef WLED_PIN_MISO + #define WLED_PIN_MISO MISO + #endif + #ifndef WLED_PIN_MOSI + #define WLED_PIN_MOSI MOSI + #endif + #ifndef WLED_PIN_SS + #define WLED_PIN_SS SS + #endif + #define WLED_SD SD + #else + #include "SD_MMC.h" + #define WLED_SD SD_MMC + #endif +#else + #define USED_STORAGE_FILESYSTEMS "LittleFS" +#endif + +// infinite loop of animation +#ifndef RECORDING_REPEAT_LOOP + #define RECORDING_REPEAT_LOOP -1 +#endif + +// Default repeat count, when not specified by preset (-1=loop, 0=play once, 2=repeat two times) +#ifndef RECORDING_REPEAT_DEFAULT + #define RECORDING_REPEAT_DEFAULT 0 +#endif + +class FSEQFile { + struct file_header_t + { + uint8_t identifier[4]; // Always PSEQ (older encodings may contain FSEQ) + uint16_t channel_data_offset; // Byte index of the channel data portion of the file + uint8_t minor_version; // Normally 0x00, optionally 0x01 is required to enable support for Extended Compression Blocks (see xLights@e33c065) + uint8_t major_version; // Currently 0x02 + uint16_t header_length; // Address of first variable, length of the header (32 bytes) + Compression Block Count * length of a Compression Block (8 bytes) + Sparse Range Count * length of a Sparse Range (12 bytes) + uint32_t channel_count; // Channel count per frame + uint32_t frame_count; // Number of frames + uint8_t step_time; // Timing interval in milliseconds + uint8_t flags; // Unused by the fpp & xLights implementations + }; + +public: + static void handlePlayRecording(); + static void loadRecording(const char* filepath, uint16_t startLed, uint16_t stopLed); + +private: + FSEQFile() {}; + + // --- CONSTANTS --- + static const int V1FSEQ_MINOR_VERSION = 0; + static const int V1FSEQ_MAJOR_VERSION = 1; + static const int V2FSEQ_MINOR_VERSION = 0; + static const int V2FSEQ_MAJOR_VERSION = 2; + static const int FSEQ_DEFAULT_STEP_TIME = 50; + + // --- Recording playback related --- + static File recordingFile; + static uint8_t colorChannels; + static int32_t recordingRepeats; + static uint32_t now; + static uint32_t next_time; + static uint16_t playbackLedStart; // first led to play animation on + static uint16_t playbackLedStop; // led after the last led to play animation on + static uint32_t frame; // current frame + static uint16_t buffer_size; // data buffer size for file read operations (1 byte buffer == ~4 fps, 3 byte buffer == ~20 fps, etc...) + static file_header_t file_header; + + // --- File reading functions --- + static inline uint32_t readUInt32(); + static inline uint32_t readUInt24(); + static inline uint16_t readUInt16(); + static inline uint8_t readUInt8(); + + static bool fileOnSD(const char* filepath); + static bool fileOnFS(const char* filepath); + + // --- Common functions --- + static void printHeaderInfo(); + static void processFrameData(); + static void clearLastPlayback(); + static bool stopBecauseAtTheEnd(); + static void playNextRecordingFrame(); +}; + +#endif \ No newline at end of file diff --git a/wled00/json.cpp b/wled00/json.cpp index 55e382a0db..affdd50a83 100644 --- a/wled00/json.cpp +++ b/wled00/json.cpp @@ -367,11 +367,35 @@ bool deserializeState(JsonObject root, byte callMode, byte presetId) if (presetsModifiedTime == 0) presetsModifiedTime = timein; } + + JsonVariant fseqVar = root["fseq"]; + if (fseqVar.is()) + { + const char* recording_path = fseqVar["file"].as(); + if (recording_path) { + int id = -1; + + JsonVariant segVar = fseqVar["seg"]; + if (segVar) { // playback on segments + if (segVar.is()) { id = segVar["id"] | -1; } // passed as json object + else if (segVar.is()) { id = segVar; } // passed as integer + else + DEBUG_PRINTLN("FSEQ: 'seg' either as integer or as json with 'id':'integer'"); + }; + + WS2812FX::Segment sg = strip.getSegment(id); + FSEQFile::loadRecording(recording_path, sg.start, sg.stop); + } + } + + doReboot = root[F("rb")] | doReboot; + if (root[F("psave")].isNull()) doReboot = root[F("rb")] | doReboot; // do not allow changing main segment while in realtime mode (may get odd results else) if (!realtimeMode) strip.setMainSegmentId(root[F("mainseg")] | strip.getMainSegmentId()); // must be before realtimeLock() if "live" + realtimeOverride = root[F("lor")] | realtimeOverride; if (realtimeOverride > 2) realtimeOverride = REALTIME_OVERRIDE_ALWAYS; if (realtimeMode && useMainSegmentOnly) { @@ -671,6 +695,7 @@ void serializeInfo(JsonObject root) case REALTIME_MODE_ARTNET: root["lm"] = F("Art-Net"); break; case REALTIME_MODE_TPM2NET: root["lm"] = F("tpm2.net"); break; case REALTIME_MODE_DDP: root["lm"] = F("DDP"); break; + case REALTIME_MODE_FSEQ: root["lm"] = F("FSEQ Recording (ROM)"); break; } if (realtimeIP[0] == 0) diff --git a/wled00/wled.cpp b/wled00/wled.cpp index a527f2967a..c103e2001f 100644 --- a/wled00/wled.cpp +++ b/wled00/wled.cpp @@ -116,7 +116,11 @@ void WLED::loop() #ifdef ESP8266 else if (!noWifiSleep) delay(1); //required to make sure ESP enters modem sleep (see #1184) - #endif + +#endif + } else { + FSEQFile::handlePlayRecording(); + } #ifdef WLED_DEBUG stripMillis = millis() - stripMillis; diff --git a/wled00/wled.h b/wled00/wled.h index ad05189305..6a9d2e4ac0 100644 --- a/wled00/wled.h +++ b/wled00/wled.h @@ -178,8 +178,11 @@ using PSRAMDynamicJsonDocument = BasicJsonDocument; #include "NodeStruct.h" #include "pin_manager.h" #include "bus_manager.h" + +#include "fseqrecord.h" #include "FX.h" + #ifndef CLIENT_SSID #define CLIENT_SSID DEFAULT_CLIENT_SSID #endif