diff --git a/.vscode/launch.json b/.vscode/launch.json index 9dce54a..300d90f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -103,7 +103,7 @@ "request": "launch", // Resolved by CMake Tools: "program": "${command:cmake.launchTargetPath}", - "args": [ "/etc/pipedal/config", "/etc/pipedal/react", "-port", "0.0.0.0:8080", "-log-level","debug" ], + "args": [ "/etc/pipedal/config", "${workspaceFolder}/vite/dist", "-port", "0.0.0.0:8080", "-log-level","debug" ], "stopAtEntry": false, "cwd": "${workspaceFolder}", "environment": [ @@ -142,7 +142,8 @@ //"[pipedal_alsa_test]" // "[wifi_channels_test]" // "[locale]" - "[pipewire_input_stream]" + //"[mod_gui_init]" + "[mod_gui_templates]" ], "stopAtEntry": false, diff --git a/.vscode/settings.json b/.vscode/settings.json index 625c4ba..d58ac81 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -115,6 +115,7 @@ "Chowdhury", "Chowdhury's", "discursus", + "distro", "distros", "dpkg", "flac", @@ -134,6 +135,7 @@ "midiclip", "midisong", "mlmodel", + "modui", "MOTU", "Netplan", "Pedalboard", @@ -161,7 +163,7 @@ "cSpell.ignoreWords": [ "nammodel" ], - "cSpell.enabled": true, + "cSpell.enabled": false, // Disable all automatic completion suggestions - only show when Ctrl+Space is pressed "editor.quickSuggestions": { diff --git a/vite/src/App.css b/2 similarity index 100% rename from vite/src/App.css rename to 2 diff --git a/CMakeLists.txt b/CMakeLists.txt index bf21e66..9864b62 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,13 +1,13 @@ cmake_minimum_required(VERSION 3.16.0) project(pipedal - VERSION 1.4.77 + VERSION 1.4.79 DESCRIPTION "PiPedal Guitar Effect Pedal For Raspberry Pi" HOMEPAGE_URL "https://rerdavies.github.io/pipedal" ) EXECUTE_PROCESS( COMMAND dpkg --print-architecture COMMAND tr -d '\n' OUTPUT_VARIABLE DEBIAN_ARCHITECTURE ) -set (DISPLAY_VERSION "PiPedal v1.4.77-Beta") +set (DISPLAY_VERSION "PiPedal v1.4.79-Beta") set (PACKAGE_ARCHITECTURE ${DEBIAN_ARCHITECTURE}) set (CMAKE_INSTALL_PREFIX "/usr/") @@ -96,13 +96,17 @@ set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "IoT guitar effect pedal for Raspberry Pi" set(CPACK_DEBIAN_PACKAGE_SECTION sound) set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) set(CPACK_DEBIAN_PACKAGE_CONTROL_STRICT_PERMISSION TRUE) -set(CPACK_DEBIAN_PACKAGE_DEPENDS "lv2-dev, authbind, curl, gpg, alsa-base| pipewire, alsa-utils" ) +set(CPACK_DEBIAN_PACKAGE_DEPENDS "lv2-dev, ffmpeg, authbind, curl, gpg, alsa-base| pipewire, alsa-utils" ) set(CPACK_DEBIAN_PACKAGE_ARCHITECTURE ${DEBIAN_ARCHITECTURE}) set(CPACK_PACKAGING_INSTALL_PREFIX /usr) set(CPACK_PROJECT_NAME ${PROJECT_NAME}) set(CPACK_PROJECT_VERSION ${PROJECT_VERSION}) set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT) -set (CPACK_STRIP_FILES true) +if (CMAKE_BUILD_TYPE MATCHES Debug) + set (CPACK_STRIP_FILES false) +else() + set (CPACK_STRIP_FILES true) +endif() set(CPACK_DEBIAN_PACKAGE_SIGN_ALGORITHM "detached") set(CPACK_DEBIAN_PACKAGE_SIGN_TYPE "origin") diff --git a/PiPedalCommon/src/AlsaSequencer.cpp b/PiPedalCommon/src/AlsaSequencer.cpp index e4c11d1..7506e02 100644 --- a/PiPedalCommon/src/AlsaSequencer.cpp +++ b/PiPedalCommon/src/AlsaSequencer.cpp @@ -319,6 +319,7 @@ namespace pipedal } if (seqHandle) { + Lv2Log::debug("Closing ALSA Sequencer"); snd_seq_close(seqHandle); seqHandle = nullptr; } @@ -608,7 +609,7 @@ namespace pipedal #ifndef NDEBUG #define MSG_DEBUG_LOG(x) \ case x: \ - Lv2Log::debug("ALSA Sequencer Message" #x); \ + Lv2Log::debug("ALSA Sequencer Message " #x); \ break; #else #define MSG_DEBUG_LOG(x) diff --git a/PiPedalCommon/src/DBusDispatcher.cpp b/PiPedalCommon/src/DBusDispatcher.cpp index 234efdf..844e7a5 100644 --- a/PiPedalCommon/src/DBusDispatcher.cpp +++ b/PiPedalCommon/src/DBusDispatcher.cpp @@ -73,6 +73,7 @@ DBusDispatcher::PostHandle DBusDispatcher::PostDelayed(const clock::duration &de void DBusDispatcher::Run() { this->eventFd = eventfd(0, 0); + threadStarted = true; this->serviceThread = std::thread( [this]() { this->ThreadProc(); }); @@ -169,7 +170,7 @@ void DBusDispatcher::ThreadProc() int eventTimeout = GetEventTimeoutMs(); if (eventTimeout != -1 && eventTimeout < pollTimeout) { - pollTimeout = eventTimeout;/*+-*/ + pollTimeout = eventTimeout; /*+-*/ } if (pollTimeout > 250 || pollTimeout == -1) { @@ -197,13 +198,13 @@ void DBusDispatcher::ThreadProc() LogTrace("DBusDispatcher", "ThreadProc", "Service thread terminated."); } -void DBusDispatcher::Wait() +void DBusDispatcher::WaitForClose() { try { - if (!threadJoined) + if (threadStarted) { - threadJoined = true; + threadStarted = false; serviceThread.join(); } } @@ -229,7 +230,7 @@ void DBusDispatcher::Stop() this->stopping = true; WakeThread(); - Wait(); + WaitForClose(); } } diff --git a/PiPedalCommon/src/HtmlHelper.cpp b/PiPedalCommon/src/HtmlHelper.cpp index 607c7aa..1b887dc 100644 --- a/PiPedalCommon/src/HtmlHelper.cpp +++ b/PiPedalCommon/src/HtmlHelper.cpp @@ -24,22 +24,20 @@ using namespace pipedal; - // non-localizable. RFC 7231 Date Format ; -static const char*INVARIANT_WEEK_DAYS[] = - { "Sun","Mon", "Tue", "Wed","Thu", "Fri","Sat"}; +static const char *INVARIANT_WEEK_DAYS[] = + {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; // non-localizable. RFC 7231 Date Format -static const char*INVARIANT_MONTHS[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep","Oct","Nov", "Dec"}; +static const char *INVARIANT_MONTHS[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; // non-localizable. RFC 7231 Date Format -static const char*FORMAT = "%s, %02d %s %04d %02d:%02d:%02d GMT"; +static const char *FORMAT = "%s, %02d %s %04d %02d:%02d:%02d GMT"; std::string HtmlHelper::timeToHttpDate() { return timeToHttpDate(time(nullptr)); } - std::string HtmlHelper::timeToHttpDate(std::filesystem::file_time_type time) { @@ -56,29 +54,30 @@ std::string HtmlHelper::timeToHttpDate(time_t time) struct tm gmTm; - if (gmtime_r(&time,&gmTm) == nullptr) + if (gmtime_r(&time, &gmTm) == nullptr) { return "Mon, 01 Jan 1970 00:00:00 GMT"; } char szBuffer[sizeof("Mon, 01 Jan 1970 00:00:00 GMT+")]; - snprintf(szBuffer,sizeof(szBuffer), FORMAT, - INVARIANT_WEEK_DAYS[gmTm.tm_wday], - gmTm.tm_mday, - INVARIANT_MONTHS[gmTm.tm_mon], - gmTm.tm_year+1900, - gmTm.tm_hour, - gmTm.tm_min, - gmTm.tm_sec); + snprintf(szBuffer, sizeof(szBuffer), FORMAT, + INVARIANT_WEEK_DAYS[gmTm.tm_wday], + gmTm.tm_mday, + INVARIANT_MONTHS[gmTm.tm_mon], + gmTm.tm_year + 1900, + gmTm.tm_hour, + gmTm.tm_min, + gmTm.tm_sec); return szBuffer; - } - static int hexN(char c) { - if (c >= '0' && c <= '9') return c-'0'; - if (c >= 'a' && c <= 'f') return 10+c-'a'; - if (c >= 'A' && c <= 'F') return 10+c-'A'; + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return 10 + c - 'a'; + if (c >= 'A' && c <= 'F') + return 10 + c - 'A'; throw std::invalid_argument("Malformed URL."); } static char hexChar(char c1, char c2) @@ -86,15 +85,19 @@ static char hexChar(char c1, char c2) return (char)((hexN(c1) << 4) + hexN(c2)); } -class SafeCharacterMap { +class SafeCharacterMap +{ bool safeCharacter[256]; + public: - SafeCharacterMap() + SafeCharacterMap() { - for (int i = 0; i < 0x20; ++i) { + for (int i = 0; i < 0x20; ++i) + { safeCharacter[i] = false; } - for (int i = 0x20; i < 0x80; ++i) { + for (int i = 0x20; i < 0x80; ++i) + { safeCharacter[i] = true; } for (int i = 0x80; i < 256; ++i) @@ -102,27 +105,25 @@ public: safeCharacter[i] = false; } const char *unsafeCharacters = ":/+?=#%@ "; - for (const char*p = unsafeCharacters; *p != 0; ++p) + for (const char *p = unsafeCharacters; *p != 0; ++p) { safeCharacter[(uint8_t)*p] = false; } } - - bool isSafeCharacter(char c) { return safeCharacter[(uint8_t)c];} + bool isSafeCharacter(char c) { return safeCharacter[(uint8_t)c]; } } g_safeCharacterMap; - -std::string HtmlHelper::encode_url_segment(const char*pStart, const char*pEnd, bool isQuerySegment ) +std::string HtmlHelper::encode_url_segment(const char *pStart, const char *pEnd, bool isQuerySegment) { std::stringstream s; - encode_url_segment(s,pStart,pEnd,isQuerySegment); + encode_url_segment(s, pStart, pEnd, isQuerySegment); return s.str(); } static char hexChars[] = "0123456789ABCDEF"; -void HtmlHelper::encode_url_segment(std::ostream&os, const char*pStart, const char *pEnd, bool isQuerySegment) +void HtmlHelper::encode_url_segment(std::ostream &os, const char *pStart, const char *pEnd, bool isQuerySegment) { while (pStart != pEnd) { @@ -130,21 +131,21 @@ void HtmlHelper::encode_url_segment(std::ostream&os, const char*pStart, const ch if (g_safeCharacterMap.isSafeCharacter(c)) { os << c; - } else if (c == ' ' && isQuerySegment) + } + else if (c == ' ' && isQuerySegment) { os << '+'; os << c; - } else { + } + else + { os << '%' << hexChars[(c >> 4) & 0x0f] << hexChars[c & 0x0F]; } } } - - - -std::string HtmlHelper::decode_url_segment(const char*pStart, const char*pEnd, bool isQuery) +std::string HtmlHelper::decode_url_segment(const char *pStart, const char *pEnd, bool isQuery) { std::stringstream s; auto p = pStart; @@ -155,16 +156,23 @@ std::string HtmlHelper::decode_url_segment(const char*pStart, const char*pEnd, b if (c == '+') { s << ' '; - } else if (c != '%') + } + else if (c != '%') { s << c; - } else { - if (pStart+2 < pEnd) + } + else + { + if (pStart + 2 < pEnd) + { + char c1 = *p; + ++p; + char c2 = *p; + ++p; + s << hexChar(c1, c2); + } + else { - char c1 = *p; ++p; - char c2 = *p; ++p; - s << hexChar(c1,c2); - } else { throw std::invalid_argument("Malformed URL."); } } @@ -172,9 +180,9 @@ std::string HtmlHelper::decode_url_segment(const char*pStart, const char*pEnd, b return s.str(); } -std::string HtmlHelper::decode_url_segment(const char*text, bool isQuery) +std::string HtmlHelper::decode_url_segment(const char *text, bool isQuery) { - return decode_url_segment(text, text + strlen(text),isQuery); + return decode_url_segment(text, text + strlen(text), isQuery); } void HtmlHelper::utf32_to_utf8_stream(std::ostream &s, uint32_t uc) @@ -182,20 +190,27 @@ void HtmlHelper::utf32_to_utf8_stream(std::ostream &s, uint32_t uc) if (uc < 0x80u) { s << (char)uc; - } else if (uc < 0x800u) { + } + else if (uc < 0x800u) + { s << (char)(0xC0 + (uc >> 6)); s << (char)(0x80 + (uc & 0x3F)); - - } else if (uc < 0x10000u) { + } + else if (uc < 0x10000u) + { s << (char)(0xE0 + (uc >> 12)); s << (char)(0x80 + ((uc >> 6) & 0x3F)); s << (char)(0x80 + (uc & 0x3F)); - } else if (uc < 0x0110000) { + } + else if (uc < 0x0110000) + { s << (char)(0xF0 + (uc >> 18)); s << (char)(0x80 + ((uc >> 12) & 0x3F)); s << (char)(0x80 + ((uc >> 6) & 0x3F)); s << (char)(0x80 + (uc & 0x3F)); - } else { + } + else + { throw std::range_error("Illegal UTF-32 character."); } } @@ -205,20 +220,22 @@ const std::string ESPECIALS = "()<>@,;:\"/[]?.="; #define MAX_FILENAME_LENGTH 96 #define MAX_SEGMENT_LENGTH 74 // 75 per RFC2047.2. But leave space for a ';' to allow easier breaking of lines. -std::string HtmlHelper::Rfc5987EncodeFileName(const std::string&name) +std::string HtmlHelper::Rfc5987EncodeFileName(const std::string &name) { // Encode all characters. Let the browser handle safe-ifying the actual name, since that's os dependent. std::stringstream s; std::string prefix = "UTF-8''"; std::string postfix = ""; s << prefix; - for (char c: name) + for (char c : name) { uint8_t uc = (uint8_t)c; if (uc < 0x20 || uc >= 0x80 || ESPECIALS.find(c) != std::string::npos) { s << '%' << hexChars[uc >> 4] << hexChars[uc & 0x0F]; - } else { + } + else + { s << c; } } @@ -226,7 +243,6 @@ std::string HtmlHelper::Rfc5987EncodeFileName(const std::string&name) return s.str(); } - #define MAX_FILE_NAME_LENGHT 96 const std::string SF_SPECIALS = " <>@;:\"\'/[]?="; @@ -249,23 +265,126 @@ std::string HtmlHelper::SafeFileName(const std::string &name) return s.str(); } -std::string HtmlHelper::HtmlEncode(const std::string& text) +std::string HtmlHelper::HtmlEncode(const std::string &text) { std::stringstream os; - for (char c: text) + for (char c : text) { - switch(c) + switch (c) { - case '<': os << "<"; break; - case '>': os << ">"; break; - case '&': os << "&"; break; - default: os << c; break; - + case '<': + os << "<"; + break; + case '>': + os << ">"; + break; + case '&': + os << "&"; + break; + default: + os << c; + break; } } return os.str(); } +static const uint64_t crc64_table[256] = { + 0x0000000000000000ULL, 0x42F0E1EBA9EA3693ULL, 0x85E1C3D753D46D26ULL, 0xC711223CFA3E5BB5ULL, + 0x493366450E42ECDFULL, 0x0BC387AEA7A8DA4CULL, 0xCCD2A5925D9681F9ULL, 0x8E224479F47CB76AULL, + 0x9266CC8A1C85D9BEULL, 0xD0962D61B56FEF2DULL, 0x17870F5D4F51B498ULL, 0x5577EEB6E6BB820BULL, + 0xDB55AACF12C73561ULL, 0x99A54B24BB2D03F2ULL, 0x5EB4691841135847ULL, 0x1C4488F3E8F96ED4ULL, + 0x663D78FF90E185EFULL, 0x24CD9914390BB37CULL, 0xE3DCBB28C335E8C9ULL, 0xA12C5AC36ADFDE5AULL, + 0x2F0E1EBA9EA36930ULL, 0x6DFEFF5137495FA3ULL, 0xAAEFDD6DCD770416ULL, 0xE81F3C86649D3285ULL, + 0xF45BB4758C645C51ULL, 0xB6AB559E258E6AC2ULL, 0x71BA77A2DFB03177ULL, 0x334A9649765A07E4ULL, + 0xBD68D2308226B08EULL, 0xFF9833DB2BCC861DULL, 0x388911E7D1F2DDA8ULL, 0x7A79F00C7818EB3BULL, + 0xCC7AF1FF21C30BDEULL, 0x8E8A101488293D4DULL, 0x499B3228721766F8ULL, 0x0B6BD3C3DBFD506BULL, + 0x854997BA2F81E701ULL, 0xC7B97651866BD192ULL, 0x00A8546D7C558A27ULL, 0x4258B586D5BFBCB4ULL, + 0x5E1C3D753D46D260ULL, 0x1CECDC9E94ACE4F3ULL, 0xDBFDFEA26E92BF46ULL, 0x990D1F49C77889D5ULL, + 0x172F5B3033043EBFULL, 0x55DFBADB9AEE082CULL, 0x92CE98E760D05399ULL, 0xD03E790CC93A650AULL, + 0xAA478900B1228E31ULL, 0xE8B768EB18C8B8A2ULL, 0x2FA64AD7E2F6E317ULL, 0x6D56AB3C4B1CD584ULL, + 0xE374EF45BF6062EEULL, 0xA1840EAE168A547DULL, 0x66952C92ECB40FC8ULL, 0x2465CD79455E395BULL, + 0x3821458AADA7578FULL, 0x7AD1A461044D611CULL, 0xBDC0865DFE733AA9ULL, 0xFF3067B657990C3AULL, + 0x711223CFA3E5BB50ULL, 0x33E2C2240A0F8DC3ULL, 0xF4F3E018F031D676ULL, 0xB60301F359DBE0E5ULL, + 0xDA050215EA6C212FULL, 0x98F5E3FE438617BCULL, 0x5FE4C1C2B9B84C09ULL, 0x1D14202910527A9AULL, + 0x93366450E42ECDF0ULL, 0xD1C685BB4DC4FB63ULL, 0x16D7A787B7FAA0D6ULL, 0x5427466C1E109645ULL, + 0x4863CE9FF6E9F891ULL, 0x0A932F745F03CE02ULL, 0xCD820D48A53D95B7ULL, 0x8F72ECA30CD7A324ULL, + 0x0150A8DAF8AB144EULL, 0x43A04931514122DDULL, 0x84B16B0DAB7F7968ULL, 0xC6418AE602954FFBULL, + 0xBC387AEA7A8DA4C0ULL, 0xFEC89B01D3679253ULL, 0x39D9B93D2959C9E6ULL, 0x7B2958D680B3FF75ULL, + 0xF50B1CAF74CF481FULL, 0xB7FBFD44DD257E8CULL, 0x70EADF78271B2539ULL, 0x321A3E938EF113AAULL, + 0x2E5EB66066087D7EULL, 0x6CAE578BCFE24BEDULL, 0xABBF75B735DC1058ULL, 0xE94F945C9C3626CBULL, + 0x676DD025684A91A1ULL, 0x259D31CEC1A0A732ULL, 0xE28C13F23B9EFC87ULL, 0xA07CF2199274CA14ULL, + 0x167FF3EACBAF2AF1ULL, 0x548F120162451C62ULL, 0x939E303D987B47D7ULL, 0xD16ED1D631917144ULL, + 0x5F4C95AFC5EDC62EULL, 0x1DBC74446C07F0BDULL, 0xDAAD56789639AB08ULL, 0x985DB7933FD39D9BULL, + 0x84193F60D72AF34FULL, 0xC6E9DE8B7EC0C5DCULL, 0x01F8FCB784FE9E69ULL, 0x43081D5C2D14A8FAULL, + 0xCD2A5925D9681F90ULL, 0x8FDAB8CE70822903ULL, 0x48CB9AF28ABC72B6ULL, 0x0A3B7B1923564425ULL, + 0x70428B155B4EAF1EULL, 0x32B26AFEF2A4998DULL, 0xF5A348C2089AC238ULL, 0xB753A929A170F4ABULL, + 0x3971ED50550C43C1ULL, 0x7B810CBBFCE67552ULL, 0xBC902E8706D82EE7ULL, 0xFE60CF6CAF321874ULL, + 0xE224479F47CB76A0ULL, 0xA0D4A674EE214033ULL, 0x67C58448141F1B86ULL, 0x253565A3BDF52D15ULL, + 0xAB1721DA49899A7FULL, 0xE9E7C031E063ACECULL, 0x2EF6E20D1A5DF759ULL, 0x6C0603E6B3B7C1CAULL, + 0xF6FAE5C07D3274CDULL, 0xB40A042BD4D8425EULL, 0x731B26172EE619EBULL, 0x31EBC7FC870C2F78ULL, + 0xBFC9838573709812ULL, 0xFD39626EDA9AAE81ULL, 0x3A28405220A4F534ULL, 0x78D8A1B9894EC3A7ULL, + 0x649C294A61B7AD73ULL, 0x266CC8A1C85D9BE0ULL, 0xE17DEA9D3263C055ULL, 0xA38D0B769B89F6C6ULL, + 0x2DAF4F0F6FF541ACULL, 0x6F5FAEE4C61F773FULL, 0xA84E8CD83C212C8AULL, 0xEABE6D3395CB1A19ULL, + 0x90C79D3FEDD3F122ULL, 0xD2377CD44439C7B1ULL, 0x15265EE8BE079C04ULL, 0x57D6BF0317EDAA97ULL, + 0xD9F4FB7AE3911DFDULL, 0x9B041A914A7B2B6EULL, 0x5C1538ADB04570DBULL, 0x1EE5D94619AF4648ULL, + 0x02A151B5F156289CULL, 0x4051B05E58BC1E0FULL, 0x87409262A28245BAULL, 0xC5B073890B687329ULL, + 0x4B9237F0FF14C443ULL, 0x0962D61B56FEF2D0ULL, 0xCE73F427ACC0A965ULL, 0x8C8315CC052A9FF6ULL, + 0x3A80143F5CF17F13ULL, 0x7870F5D4F51B4980ULL, 0xBF61D7E80F251235ULL, 0xFD913603A6CF24A6ULL, + 0x73B3727A52B393CCULL, 0x31439391FB59A55FULL, 0xF652B1AD0167FEEAULL, 0xB4A25046A88DC879ULL, + 0xA8E6D8B54074A6ADULL, 0xEA16395EE99E903EULL, 0x2D071B6213A0CB8BULL, 0x6FF7FA89BA4AFD18ULL, + 0xE1D5BEF04E364A72ULL, 0xA3255F1BE7DC7CE1ULL, 0x64347D271DE22754ULL, 0x26C49CCCB40811C7ULL, + 0x5CBD6CC0CC10FAFCULL, 0x1E4D8D2B65FACC6FULL, 0xD95CAF179FC497DAULL, 0x9BAC4EFC362EA149ULL, + 0x158E0A85C2521623ULL, 0x577EEB6E6BB820B0ULL, 0x906FC95291867B05ULL, 0xD29F28B9386C4D96ULL, + 0xCEDBA04AD0952342ULL, 0x8C2B41A1797F15D1ULL, 0x4B3A639D83414E64ULL, 0x09CA82762AAB78F7ULL, + 0x87E8C60FDED7CF9DULL, 0xC51827E4773DF90EULL, 0x020905D88D03A2BBULL, 0x40F9E43324E99428ULL, + 0x2CFFE7D5975E55E2ULL, 0x6E0F063E3EB46371ULL, 0xA91E2402C48A38C4ULL, 0xEBEEC5E96D600E57ULL, + 0x65CC8190991CB93DULL, 0x273C607B30F68FAEULL, 0xE02D4247CAC8D41BULL, 0xA2DDA3AC6322E288ULL, + 0xBE992B5F8BDB8C5CULL, 0xFC69CAB42231BACFULL, 0x3B78E888D80FE17AULL, 0x7988096371E5D7E9ULL, + 0xF7AA4D1A85996083ULL, 0xB55AACF12C735610ULL, 0x724B8ECDD64D0DA5ULL, 0x30BB6F267FA73B36ULL, + 0x4AC29F2A07BFD00DULL, 0x08327EC1AE55E69EULL, 0xCF235CFD546BBD2BULL, 0x8DD3BD16FD818BB8ULL, + 0x03F1F96F09FD3CD2ULL, 0x41011884A0170A41ULL, 0x86103AB85A2951F4ULL, 0xC4E0DB53F3C36767ULL, + 0xD8A453A01B3A09B3ULL, 0x9A54B24BB2D03F20ULL, 0x5D45907748EE6495ULL, 0x1FB5719CE1045206ULL, + 0x919735E51578E56CULL, 0xD367D40EBC92D3FFULL, 0x1476F63246AC884AULL, 0x568617D9EF46BED9ULL, + 0xE085162AB69D5E3CULL, 0xA275F7C11F7768AFULL, 0x6564D5FDE549331AULL, 0x279434164CA30589ULL, + 0xA9B6706FB8DFB2E3ULL, 0xEB46918411358470ULL, 0x2C57B3B8EB0BDFC5ULL, 0x6EA7525342E1E956ULL, + 0x72E3DAA0AA188782ULL, 0x30133B4B03F2B111ULL, 0xF7021977F9CCEAA4ULL, 0xB5F2F89C5026DC37ULL, + 0x3BD0BCE5A45A6B5DULL, 0x79205D0E0DB05DCEULL, 0xBE317F32F78E067BULL, 0xFCC19ED95E6430E8ULL, + 0x86B86ED5267CDBD3ULL, 0xC4488F3E8F96ED40ULL, 0x0359AD0275A8B6F5ULL, 0x41A94CE9DC428066ULL, + 0xCF8B0890283E370CULL, 0x8D7BE97B81D4019FULL, 0x4A6ACB477BEA5A2AULL, 0x089A2AACD2006CB9ULL, + 0x14DEA25F3AF9026DULL, 0x562E43B4931334FEULL, 0x913F6188692D6F4BULL, 0xD3CF8063C0C759D8ULL, + 0x5DEDC41A34BBEEB2ULL, 0x1F1D25F19D51D821ULL, 0xD80C07CD676F8394ULL, 0x9AFCE626CE85B507ULL}; + +uint64_t HtmlHelper::crc64(uint8_t *data, size_t length, uint64_t crc) +{ + + crc = crc ^ uint64_t(-1); + for (size_t i = 0; i < length; ++i) + { + uint8_t c = data[i]; + crc = crc64_table[(crc ^ (uint8_t)c) & 0xFF] ^ (crc >> 8); + } + return crc ^ uint64_t(-1); +} +uint64_t HtmlHelper::crc64(const std::string&value, uint64_t crc) +{ + crc = crc ^ uint64_t(-1); + for (auto c: value) + { + crc = crc64_table[(crc ^ (uint8_t)c) & 0xFF] ^ (crc >> 8); + } + return crc ^ uint64_t(-1); +} + + +std::string HtmlHelper::generateEtag(const std::filesystem::path &path) +{ + uint64_t crc = 0; + crc = crc64(path.string()); + auto fTime = std::filesystem::last_write_time(path); + crc = crc64((uint8_t*)&fTime, sizeof(fTime)); + return std::to_string(crc); +} diff --git a/PiPedalCommon/src/include/DBusDispatcher.hpp b/PiPedalCommon/src/include/DBusDispatcher.hpp index af4288c..1da5414 100644 --- a/PiPedalCommon/src/include/DBusDispatcher.hpp +++ b/PiPedalCommon/src/include/DBusDispatcher.hpp @@ -24,7 +24,7 @@ public: void Run(); // Stop everything, shutting down gracefully. void Stop(); - void Wait(); + void WaitForClose(); bool IsFinished(); // Stop, but don't wait. Suitable for use in a signal handler. @@ -78,7 +78,7 @@ private: std::atomic stopping; std::vector postedEvents; - bool threadJoined; + bool threadStarted; std::thread serviceThread; std::mutex postMutex; }; \ No newline at end of file diff --git a/PiPedalCommon/src/include/HtmlHelper.hpp b/PiPedalCommon/src/include/HtmlHelper.hpp index 215f5ac..0af60f2 100644 --- a/PiPedalCommon/src/include/HtmlHelper.hpp +++ b/PiPedalCommon/src/include/HtmlHelper.hpp @@ -29,12 +29,16 @@ namespace pipedal { class HtmlHelper { public: + static std::string timeToHttpDate(); static std::string timeToHttpDate(time_t time); static std::string timeToHttpDate(std::filesystem::file_time_type time); static std::string encode_url_segment(const char*pStart, const char*pEnd, bool isQuerySegment = false); + static std::string encode_url_segment(const std::string &segment, bool isQuerySegment = false) { + return encode_url_segment(segment.c_str(), segment.c_str() + segment.length(), isQuerySegment); + } static void encode_url_segment(std::ostream&os, const char*pStart, const char *pEnd, bool isQuerySegment = false); static void encode_url_segment(std::ostream&os, const std::string&segment, bool isQuerySegment = false) { @@ -54,6 +58,11 @@ public: static std::string SafeFileName(const std::string &name); static std::string HtmlEncode(const std::string& text); + static uint64_t crc64(uint8_t *data, size_t length,uint64_t crc = 0); + static uint64_t crc64(const std::string&value, uint64_t crc = 0); + + static std::string generateEtag(const std::filesystem::path &path); + }; } // namespace. \ No newline at end of file diff --git a/PiPedalCommon/src/include/json.hpp b/PiPedalCommon/src/include/json.hpp index aa193aa..def138f 100644 --- a/PiPedalCommon/src/include/json.hpp +++ b/PiPedalCommon/src/include/json.hpp @@ -607,7 +607,7 @@ namespace pipedal if (!obj) { write_raw("null"); } else { - write(obj.get()); + write(obj.value()); } } diff --git a/PiPedalCommon/src/util.cpp b/PiPedalCommon/src/util.cpp index 828ccf5..52838ee 100644 --- a/PiPedalCommon/src/util.cpp +++ b/PiPedalCommon/src/util.cpp @@ -218,6 +218,10 @@ bool pipedal::IsSubdirectory(const std::filesystem::path &path, const std::files if ((*i) != (*iPath)) { + if (*i == "" && ++i == basePath.end()) // match xyz/ (trailing /) + { + break; + } return false; } ++iPath; diff --git a/README.md b/README.md index 9545dbe..4645e27 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ -Download: v1.4.77 +Download: v1.4.79 Website: [https://rerdavies.github.io/pipedal](https://rerdavies.github.io/pipedal). Documentation: [https://rerdavies.github.io/pipedal/Documentation.html](https://rerdavies.github.io/pipedal/Documentation.html). -#### NEW version 1.4.77 Release. See the [release notes](https://rerdavies.github.io/pipedal/ReleaseNotes) for details. New Phaser and Graphic Equalizer plugins. +#### NEW version 1.4.79 Release. See the [release notes](https://rerdavies.github.io/pipedal/ReleaseNotes) for details. New Phaser and Graphic Equalizer plugins.   @@ -56,8 +56,12 @@ https://github.com/user-attachments/assets/9a9fd0c6-78fc-4284-8b44-6a1929c00cc6 ### [Optimizing Audio Latency](https://rerdavies.github.io/pipedal/AudioLatency.html) ### [Command-Line Configuration of PiPedal](https://rerdavies.github.io/pipedal/CommandLine.html) ### [Changing the Web Server Port](https://rerdavies.github.io/pipedal/ChangingTheWebServerPort.html) + +%nbsp; + ### [Using LV2 Audio Plugins](https://rerdavies.github.io/pipedal/UsingLv2Plugins.md) ### [Which LV2 Plugins does PiPedal support?](https://rerdavies.github.io/pipedal/WhichLv2PluginsAreSupported.html) +### [Support for LV2 Plugins with MOD User Interfaces](https://rerdavies.github.io/pipedal/ModUiSupport.html)    diff --git a/_includes/gallery.html b/_includes/gallery.html index 35a8db4..978ac2e 100644 --- a/_includes/gallery.html +++ b/_includes/gallery.html @@ -29,7 +29,7 @@ let galleryFrame = 0; let calculateWidth = function () { - width = Math.min(maxWidth, window.innerWidth * 0.8); + width = Math.min(maxWidth, document.documentElement.clientWidth * 0.8); height = width * aspectY / aspectX; frameWidth = width + borderWidth * 2; frameHeight = height + borderWidth * 2; diff --git a/artifacts/FavIcon.svgz b/artifacts/FavIcon.svgz new file mode 100644 index 0000000..700e69e Binary files /dev/null and b/artifacts/FavIcon.svgz differ diff --git a/artifacts/mod-ui.svgz b/artifacts/mod-ui.svgz new file mode 100644 index 0000000..d8e198c Binary files /dev/null and b/artifacts/mod-ui.svgz differ diff --git a/artifacts/pp-ui.svgz b/artifacts/pp-ui.svgz new file mode 100644 index 0000000..dfc4be1 Binary files /dev/null and b/artifacts/pp-ui.svgz differ diff --git a/artifacts/red-light-on.png b/artifacts/red-light-on.png new file mode 100644 index 0000000..2cfe71d --- /dev/null +++ b/artifacts/red-light-on.png @@ -0,0 +1,112 @@ + + + + diff --git a/artifacts/red-light.svgz b/artifacts/red-light.svgz new file mode 100644 index 0000000..6df1260 Binary files /dev/null and b/artifacts/red-light.svgz differ diff --git a/debugDocs.sh b/debugDocs.sh new file mode 100755 index 0000000..2261a47 --- /dev/null +++ b/debugDocs.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Run local jeckyl-hosted sevever for GitHub documentation page. +# See https://jekyllrb.com/docs/installation/ for instructions on how to isntall jeckyll locally. +# Then +# cd docs +# bundle install +# +cd docs +bundle exec jekyll serve --host \ No newline at end of file diff --git a/docs/Documentation.md b/docs/Documentation.md index 7cbb9fd..7703e50 100644 --- a/docs/Documentation.md +++ b/docs/Documentation.md @@ -16,8 +16,11 @@ #### [Optimizing Audio Latency](AudioLatency.md) #### [Command-Line Configuration of PiPedal](CommandLine.md) #### [Changing the Web Server Port](ChangingTheWebServerPort.md) + +  #### [Using LV2 Audio Plugins](UsingLv2Plugins.md) #### [Which LV2 Plugins does PiPedal support?](WhichLv2PluginsAreSupported.md) +#### [LV2 Plugins with MOD User Interfaces](ModUiSupport.md)   diff --git a/docs/Gemfile b/docs/Gemfile index 8c717b7..2fc6a89 100644 --- a/docs/Gemfile +++ b/docs/Gemfile @@ -31,3 +31,5 @@ gem "wdm", "~> 0.1.1", :platforms => [:mingw, :x64_mingw, :mswin] # Lock `http_parser.rb` gem to `v0.6.x` on JRuby builds since newer versions of the gem # do not have a Java counterpart. gem "http_parser.rb", "~> 0.6.0", :platforms => [:jruby] + +gem "webrick", "~> 1.9" diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index 4b10e71..7b3f5fa 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -205,14 +205,14 @@ GEM rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) mercenary (0.3.6) + mini_portile2 (2.8.9) minima (2.5.1) jekyll (>= 3.5, < 5.0) jekyll-feed (~> 0.9) jekyll-seo-tag (~> 2.1) minitest (5.19.0) - nokogiri (1.18.8-aarch64-linux-gnu) - racc (~> 1.4) - nokogiri (1.18.8-x86_64-linux-gnu) + nokogiri (1.18.8) + mini_portile2 (~> 2.8.2) racc (~> 1.4) octokit (4.25.1) faraday (>= 1, < 3) @@ -249,6 +249,7 @@ GEM unf_ext unf_ext (0.0.8.2) unicode-display_width (1.8.0) + webrick (1.9.1) PLATFORMS aarch64-linux @@ -262,6 +263,7 @@ DEPENDENCIES tzinfo (~> 1.2) tzinfo-data wdm (~> 0.1.1) + webrick (~> 1.9) BUNDLED WITH 2.4.19 diff --git a/docs/Installing.md b/docs/Installing.md index 24ee378..f8935b8 100644 --- a/docs/Installing.md +++ b/docs/Installing.md @@ -13,18 +13,18 @@ page_icon: img/Install4.jpg Download the most recent Debian (.deb) package for your platform: -- [Raspberry Pi OS bookworm (aarch64) v1.4.77](https://github.com/rerdavies/pipedal/releases/download/v1.4.77/pipedal_1.4.77_arm64.deb) -- [Ubuntu 24.x (aarch64) v1.4.77](https://github.com/rerdavies/pipedal/releases/download/v1.4.77/pipedal_1.4.77_arm64.deb) -- [Ubuntu 24.x (amd64) v1.4.77](https://github.com/rerdavies/pipedal/releases/download/v1.4.77/pipedal_1.4.77_amd64.deb) +- [Raspberry Pi OS bookworm (aarch64) v1.4.79](https://github.com/rerdavies/pipedal/releases/download/v1.4.79/pipedal_1.4.79_arm64.deb) +- [Ubuntu 24.x (aarch64) v1.4.79](https://github.com/rerdavies/pipedal/releases/download/v1.4.79/pipedal_1.4.79_arm64.deb) +- [Ubuntu 24.x (amd64) v1.4.79](https://github.com/rerdavies/pipedal/releases/download/v1.4.79/pipedal_1.4.79_amd64.deb) -Version 1.4.77 has been tested on Raspberry Pi OS bookworm, Ubuntu 24.04 (amd64), and Ubuntu 24.10 (aarch64). Download the appropriate package for your platform, and install using the following procedure: +Version 1.4.79 has been tested on Raspberry Pi OS bookworm, Ubuntu 24.04 (amd64), and Ubuntu 24.10 (aarch64). Download the appropriate package for your platform, and install using the following procedure: ``` sudo apt update sudo apt upgrade cd ~/Downloads - sudo apt-get install ./pipedal_1.4.77_arm64.deb + sudo apt-get install ./pipedal_1.4.79_arm64.deb ``` You MUST use `apt-get`. `apt` will not install downloaded packages; and `dpkg -i` will not install dependencies. diff --git a/docs/ModUiSupport.md b/docs/ModUiSupport.md new file mode 100644 index 0000000..993b4f9 --- /dev/null +++ b/docs/ModUiSupport.md @@ -0,0 +1,35 @@ +--- +page_icon: img/moduiThumb.jpg +icon_width: 120px +--- +## Support for LV2 Plugins with MOD User Interfaces + +{% include pageIconL.html %} + +Some LV2 plugins (currently a minority of plugins) provide custom plugin user interfaces based on [MOD Audio's](https://mod.audio/desktop/) +[ModUi framework](https://wiki.mod.audio/wiki/MOD_Web_GUI_Framework). When using such a plugin, you can choose whether to use the MOD Web GUI Framework user interface (the MOD UI) or the default PiPedal user interface. For the most part, the same functionality is available in both interfaces. + +Plugins that are distributed via Linux distributions usually do not implement MOD user interfaces; but all (or almost all) of the plugins that are available from the [PatchStorage website](https://patchstorage.com/platform/lv2-plugins/) do implement MOD user interfaces. Many plugins that are available from GitHub or other sources also implement MOD user interfaces. Often you can find updated versions of a plugin that is published by a distro on PatchStorage which does provide a MOD UI. + +Here's an example of a MOD UI for the ZAM Eq2 plugin: + +ZAM Eq2 MOD UI + + +And the PiPedal UI for the same plugin: + +ZAM Eq2 PiPedal UI + +Which UI you use is entirely up to you. MOD UIs tend to be a bit unfriendly for small-format devices like phones, but they do look great in a desktop browser. Sometimes the MOD UI will make better use of available screen space; sometimes the Pipedal UI will look better. It really depends on the plugin and your personal preferences. In this particular case, the MOD UI is more space efficient, but that is not always true. + +To select which user interface to use, tap on the UI selection button in the toolbar for the plugin. If the plugin does not provide a MOD UI, the button will be disabled. + +MOD UI selection button + +Support for MOD UIs in PiPedal is still experimental, so you may encounter some issues. PiPedal does not yet support all of the features of the MOD UI framework, not because it can't, but because we haven't been able to locate examples of plugins that use those features, which makes it difficult to test. If you encounter any issues, please report them on PiPedal's [GitHub Issues page](https://github.com/rerdavies/pipedal/issues), and we will do our best to provide quick fixes. + +  + + +-------- +[<< Which Plugins are Supported?](WhichLv2PluginsAreSupported.md) | [Up](Documentation.md) | [BuildingPiPedal from Source >>](BuildingPiPedalFromSource.md) diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index 221bee3..dff5b85 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -1,4 +1,38 @@ # Release Notes +## Pipedal 1.4.79 Beta +The Big Feature: + +- Support for custom MOD User Interfaces for plugins that prove a MOD UI. See the [MOD UI documentation page](https://rerdavies.github.io/pipedal/ModUiSupport.html) for details on how to use MOD User Interfaces with PiPedal. This feature is experimental. If you experience problems with plugins that have MOD user interfaces, please report them on the [PiPedal GitHub Issues page](https://github.com/rerdavies/pipedal/issues). + +New in this relase: + +- PiPedal remembers which plugin you were viewing when you reload a preset. +- Keep Screen On setting in the PiPedal Remote Client (requires updated Android client). +- Lock Screen Orientation setting in the PiPedal Remote Client (requires updated Android client). +- Disable the default startup preset if it has previously caused a crash of the PiPedal process. + +If you are using the Android PiPedal remote app, you should make sure it has upgraded to the latest version. The new Remote app has been fully rolled out and is now available in the Google Play Store. It should update automatically, but you can check by opening the App Info for PiPedal Remote. If an "Update" button is displayed, click it to upgrade to the current version right away. If you have received the update already, you should notice differences in the App startup procedure (notably an animated splash screen). + +The PiPedal Remote Android app has been updated to provide the following new features: + +- new Keep Screen On settings. +- new Lock Screen Orientation setting. +- Edit controls are automatically repositioned above the Keyboard IME when it opens. +- The Zoomed Control feature (large controls in landscape mode on phones) has been removed. Improved scrolling provides a better solution to the problem of controls being obscured by the keyboard IME. +- Support for Android 16 Edge-to-Edge display. +- Reduced display flickering while connecting. +- Complete support for Night Mode (the Android UI now follows the PiPedal Theme setting). +- Shiny new graphics on the opening screen in support of Android 16's Edge-to-Edge display feature (but you can see it in other versions of Android as well). + + +Bug Fixes: + +- The "Listen for Message" buttons in the MIDI Bindings dialog now ignore CC0 (Bank Select), and CC32-63 (CC LSB) MIDI message, since CC0 is used to control preset selection. +- Fixed errors loading plugins with MIDI Event ports. +- Changing volume control on low-resolution displays breaks web page. +- MIDI binding for bypass controls have been restored after having been accidentally disabled in v1.4.77. + + ## PiPedal 1.4.77 Beta New TooB Player plugin; improvements to MIDI input handling; and a slew of fit-and finish diff --git a/docs/WhichLv2PluginsAreSupported.md b/docs/WhichLv2PluginsAreSupported.md index 6e3cf9a..dce42bc 100644 --- a/docs/WhichLv2PluginsAreSupported.md +++ b/docs/WhichLv2PluginsAreSupported.md @@ -9,9 +9,8 @@ following conditions: - Must be remotely controllable (no hard dependency on GUI-only controls), which is true of all but a tiny minority of LV2 plugins. + If you install a new LV2 plugin, PiPedal will detect the change and make it available immediately. Wait a few seconds, and the newly-installed plugin should show up in the list of available plugins. -PiPedal does not currently allow plugins to provide custom user interfaces; it displays controls that are declared by the LV2 plugin without customization. In practice, this means that LV2 plugins with simple sets of controls work well, but those with complex sets of controls can be difficult to use. However, we would be pleased to collaborate with developers who need more than the basic set of controls. This is a feature set that Pipedal needs to address, so we would be happy to collaborate with other developers on this. Please contact rerdavies at gmail.com for further details. - -------- -[<< Using LV2 Audio Plugins](UsingLv2Plugins.md) | [Up](Documentation.md) | [BuildingPiPedal from Source >>](BuildingPiPedalFromSource.md) +[<< Using LV2 Audio Plugins](UsingLv2Plugins.md) | [Up](Documentation.md) | [MOD UI Support >>](ModUiSupport.md) diff --git a/docs/_config.yml b/docs/_config.yml index 5bd324b..caf2865 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -36,6 +36,7 @@ header_pages: theme: minima plugins: - jekyll-feed + - jekyll-seo-tag include: - .well-known diff --git a/docs/_includes/gallery.html b/docs/_includes/gallery.html index e224e83..d0dfefb 100644 --- a/docs/_includes/gallery.html +++ b/docs/_includes/gallery.html @@ -34,7 +34,7 @@ let galleryFrame = 0; let calculateWidth = function () { - width = Math.min(maxWidth, window.innerWidth * 0.8); + width = Math.min(maxWidth, document.documentElement.clientWidth * 0.8); height = width * aspectY / aspectX; frameWidth = width + borderWidth * 2; frameHeight = height + borderWidth * 2; diff --git a/docs/_includes/pageIconL.html b/docs/_includes/pageIconL.html index a15ccce..5a3a026 100644 --- a/docs/_includes/pageIconL.html +++ b/docs/_includes/pageIconL.html @@ -1,7 +1,4 @@
-
-

Generated by DALL-E 2

-
diff --git a/docs/download.md b/docs/download.md index 6e4fb8e..8e8b412 100644 --- a/docs/download.md +++ b/docs/download.md @@ -4,9 +4,9 @@ Download the most recent Debian (.deb) package for your platform: -- [Raspberry Pi OS bookworm (aarch64) v1.4.77 Beta](https://github.com/rerdavies/pipedal/releases/download/v1.4.77/pipedal_1.4.77_arm64.deb) -- [Ubuntu 24.x (aarch64) v1.4.77 Alpha](https://github.com/rerdavies/pipedal/releases/download/v1.4.77/pipedal_1.4.77_arm64.deb) -- [Ubuntu 24.x (amd64) v1.4.77 Alpha](https://github.com/rerdavies/pipedal/releases/download/v1.4.77/pipedal_1.4.77_amd64.deb) +- [Raspberry Pi OS bookworm (aarch64) v1.4.79 Beta](https://github.com/rerdavies/pipedal/releases/download/v1.4.79/pipedal_1.4.79_arm64.deb) +- [Ubuntu 24.x (aarch64) v1.4.79 Alpha](https://github.com/rerdavies/pipedal/releases/download/v1.4.79/pipedal_1.4.79_arm64.deb) +- [Ubuntu 24.x (amd64) v1.4.79 Alpha](https://github.com/rerdavies/pipedal/releases/download/v1.4.79/pipedal_1.4.79_amd64.deb) Install the package by running @@ -14,7 +14,7 @@ Install the package by running ``` sudo apt update cd ~/Downloads - sudo apt-get install ./pipedal_1.4.77_arm64.deb + sudo apt-get install ./pipedal_1.4.79_arm64.deb ``` You MUST use `apt-get` to install the package. `apt install` will NOT install the package correctly. The message about missing permissions given by `apt-get` is expected, and can be safely ignored. diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000..cc7e70b Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 0000000..4ab2da1 --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,264 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/img/moduiThumb.jpg b/docs/img/moduiThumb.jpg new file mode 100644 index 0000000..a6ad8c4 Binary files /dev/null and b/docs/img/moduiThumb.jpg differ diff --git a/docs/img/selectUi.png b/docs/img/selectUi.png new file mode 100644 index 0000000..07d0592 Binary files /dev/null and b/docs/img/selectUi.png differ diff --git a/docs/img/zam-modui.jpg b/docs/img/zam-modui.jpg new file mode 100644 index 0000000..334d0fa Binary files /dev/null and b/docs/img/zam-modui.jpg differ diff --git a/docs/img/zam-pp-ui.png b/docs/img/zam-pp-ui.png new file mode 100644 index 0000000..7d7fe3f Binary files /dev/null and b/docs/img/zam-pp-ui.png differ diff --git a/lv2/aarch64/ToobAmp.lv2/CabIR.ttl b/lv2/aarch64/ToobAmp.lv2/CabIR.ttl index a5cdcb5..fa85f89 100644 --- a/lv2/aarch64/ToobAmp.lv2/CabIR.ttl +++ b/lv2/aarch64/ToobAmp.lv2/CabIR.ttl @@ -93,7 +93,7 @@ cabir:impulseFile3 doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ TooB Cab IR is a convolution-based guitar cabinet impulse response simulator. diff --git a/lv2/aarch64/ToobAmp.lv2/CabSim.ttl b/lv2/aarch64/ToobAmp.lv2/CabSim.ttl index 70ba918..8a17811 100644 --- a/lv2/aarch64/ToobAmp.lv2/CabSim.ttl +++ b/lv2/aarch64/ToobAmp.lv2/CabSim.ttl @@ -50,7 +50,7 @@ toob:frequencyResponseVector doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; mod:brand "TooB"; mod:label "TooB CabSim"; diff --git a/lv2/aarch64/ToobAmp.lv2/ConvolutionReverb.ttl b/lv2/aarch64/ToobAmp.lv2/ConvolutionReverb.ttl index 3165808..ee9eb6c 100644 --- a/lv2/aarch64/ToobAmp.lv2/ConvolutionReverb.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ConvolutionReverb.ttl @@ -53,7 +53,7 @@ toobimpulse:impulseFile doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ Convolution reverb is a notoriously compute-intensive effect. If you are having performance issues, use the Max T control to constrain the length of the impulse file to diff --git a/lv2/aarch64/ToobAmp.lv2/ConvolutionReverbStereo.ttl b/lv2/aarch64/ToobAmp.lv2/ConvolutionReverbStereo.ttl index a9ff502..2328645 100644 --- a/lv2/aarch64/ToobAmp.lv2/ConvolutionReverbStereo.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ConvolutionReverbStereo.ttl @@ -51,7 +51,7 @@ toobimpulse:impulseFile doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ diff --git a/lv2/aarch64/ToobAmp.lv2/InputStage.ttl b/lv2/aarch64/ToobAmp.lv2/InputStage.ttl index 6ec1c19..95cac11 100644 --- a/lv2/aarch64/ToobAmp.lv2/InputStage.ttl +++ b/lv2/aarch64/ToobAmp.lv2/InputStage.ttl @@ -66,7 +66,7 @@ inputStage:filterGroup doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; mod:brand "TooB"; mod:label "TooB Input"; diff --git a/lv2/aarch64/ToobAmp.lv2/PowerStage2.ttl b/lv2/aarch64/ToobAmp.lv2/PowerStage2.ttl index eeede7d..f7cb004 100644 --- a/lv2/aarch64/ToobAmp.lv2/PowerStage2.ttl +++ b/lv2/aarch64/ToobAmp.lv2/PowerStage2.ttl @@ -68,7 +68,7 @@ pstage:stage3 doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; mod:brand "TooB"; mod:label "Power Stage"; diff --git a/lv2/aarch64/ToobAmp.lv2/SpectrumAnalyzer.ttl b/lv2/aarch64/ToobAmp.lv2/SpectrumAnalyzer.ttl index 70bda8c..84011d8 100644 --- a/lv2/aarch64/ToobAmp.lv2/SpectrumAnalyzer.ttl +++ b/lv2/aarch64/ToobAmp.lv2/SpectrumAnalyzer.ttl @@ -57,7 +57,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment "TooB spectrum analyzer" ; mod:brand "TooB"; diff --git a/lv2/aarch64/ToobAmp.lv2/ToneStack.ttl b/lv2/aarch64/ToobAmp.lv2/ToneStack.ttl index f9d51b9..05526a2 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToneStack.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToneStack.ttl @@ -57,7 +57,7 @@ tonestack:eqGroup doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; uiext:ui ; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobAmp.so b/lv2/aarch64/ToobAmp.lv2/ToobAmp.so index b6357f6..e0d9698 100644 Binary files a/lv2/aarch64/ToobAmp.lv2/ToobAmp.so and b/lv2/aarch64/ToobAmp.lv2/ToobAmp.so differ diff --git a/lv2/aarch64/ToobAmp.lv2/ToobAmpUI.so b/lv2/aarch64/ToobAmp.lv2/ToobAmpUI.so index 92c76eb..a78d9a8 100644 Binary files a/lv2/aarch64/ToobAmp.lv2/ToobAmpUI.so and b/lv2/aarch64/ToobAmp.lv2/ToobAmpUI.so differ diff --git a/lv2/aarch64/ToobAmp.lv2/ToobChorus.ttl b/lv2/aarch64/ToobAmp.lv2/ToobChorus.ttl index 44e6b0b..6b0d594 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobChorus.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobChorus.ttl @@ -40,7 +40,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ Emulation of a Boss CE-2 Chorus. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobDelay.ttl b/lv2/aarch64/ToobAmp.lv2/ToobDelay.ttl index 97573a7..d8e95a7 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobDelay.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobDelay.ttl @@ -41,7 +41,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ A straightforward no-frills digital delay. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobFlanger.ttl b/lv2/aarch64/ToobAmp.lv2/ToobFlanger.ttl index 7b2b30d..a4601a6 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobFlanger.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobFlanger.ttl @@ -41,7 +41,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ Emulation of a Boss BF-2 Flanger, based on circuit analysis and simulation of the original. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobFlangerStereo.ttl b/lv2/aarch64/ToobAmp.lv2/ToobFlangerStereo.ttl index 5a85fdd..dcef7d7 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobFlangerStereo.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobFlangerStereo.ttl @@ -41,7 +41,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ Digital emulation of a Boss BF-2 Flanger. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobFreeverb.ttl b/lv2/aarch64/ToobAmp.lv2/ToobFreeverb.ttl index 94a7c65..c19a1da 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobFreeverb.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobFreeverb.ttl @@ -41,7 +41,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ Toob Freeverb is an Lv2 implementation of the famous Freeverb reverb effect. FreeVerb delivers a well-balanced reverb with very little tonal coloration. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobGraphicEq.ttl b/lv2/aarch64/ToobAmp.lv2/ToobGraphicEq.ttl index fcf298c..7581cc8 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobGraphicEq.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobGraphicEq.ttl @@ -41,7 +41,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ A 7 octave graphic equalizer, with frequencies chosen to be useful for EQ-ing guitar signals. """ ; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobLooperFour.ttl b/lv2/aarch64/ToobAmp.lv2/ToobLooperFour.ttl index cb2c508..4cc44e8 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobLooperFour.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobLooperFour.ttl @@ -92,7 +92,7 @@ myprefix:output_group doap:license ; doap:maintainer ; lv2:minorVersion 1 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; ui:ui ; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobLooperOne.ttl b/lv2/aarch64/ToobAmp.lv2/ToobLooperOne.ttl index 9efd0ca..e37f594 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobLooperOne.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobLooperOne.ttl @@ -78,7 +78,7 @@ myprefix:output_group doap:license ; doap:maintainer ; lv2:minorVersion 1 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; ui:ui ; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobML.ttl b/lv2/aarch64/ToobAmp.lv2/ToobML.ttl index 5e6e3ee..de2a07a 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobML.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobML.ttl @@ -67,7 +67,7 @@ toobml:sagGroup doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ The TooB ML Amplifier plugin provides emulation of a variety of amplifiers and overdrive pedals that are implemented using neural-network-based machine learning models of real amplifiers. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobMix.ttl b/lv2/aarch64/ToobAmp.lv2/ToobMix.ttl index 2e397d4..aa0ae6a 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobMix.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobMix.ttl @@ -40,7 +40,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ Remix a stereo input signal. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobNeuralAmpModeler.ttl b/lv2/aarch64/ToobAmp.lv2/ToobNeuralAmpModeler.ttl index 5355066..67ede49 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobNeuralAmpModeler.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobNeuralAmpModeler.ttl @@ -61,7 +61,7 @@ toobNam:eqGroup doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ A port of Steven Atkinson's Neural Amp Modeler to LV2. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobNoiseGate.ttl b/lv2/aarch64/ToobAmp.lv2/ToobNoiseGate.ttl index 932ca56..5672117 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobNoiseGate.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobNoiseGate.ttl @@ -54,7 +54,7 @@ noisegate:envelope_group doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ A noise gate is an audio processing tool that controls the volume of an audio signal by allowing it to pass through only when it exceeds a set threshold. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobPhaser.ttl b/lv2/aarch64/ToobAmp.lv2/ToobPhaser.ttl index 6963a38..3676f05 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobPhaser.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobPhaser.ttl @@ -40,7 +40,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ A loose emulation of an MXR® Phase 90 Phaser. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobPlayer.ttl b/lv2/aarch64/ToobAmp.lv2/ToobPlayer.ttl index 000489f..c11a17b 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobPlayer.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobPlayer.ttl @@ -60,7 +60,7 @@ toobPlayer:seek doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ Play audio files. Audio files can optionally be loooped by pressing the "Set loop" button. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobRecordMono.ttl b/lv2/aarch64/ToobAmp.lv2/ToobRecordMono.ttl index b127513..841fb12 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobRecordMono.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobRecordMono.ttl @@ -51,7 +51,7 @@ recordPrefix:audioFile doap:license ; doap:maintainer ; lv2:minorVersion 1 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; ui:ui ; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobRecordStereo.ttl b/lv2/aarch64/ToobAmp.lv2/ToobRecordStereo.ttl index b466568..9669716 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobRecordStereo.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobRecordStereo.ttl @@ -88,7 +88,7 @@ myprefix:loop3_group doap:license ; doap:maintainer ; lv2:minorVersion 1 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; ui:ui ; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobTuner.ttl b/lv2/aarch64/ToobAmp.lv2/ToobTuner.ttl index 90b5392..bcaf601 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobTuner.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobTuner.ttl @@ -40,7 +40,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ TooB Tuner is a chromatic guitar tuner. """ ; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobVolume.ttl b/lv2/aarch64/ToobAmp.lv2/ToobVolume.ttl index d78713b..be47dd1 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobVolume.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobVolume.ttl @@ -39,7 +39,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 63 ; + lv2:microVersion 64 ; rdfs:comment """ Volume control. diff --git a/lv2/x86_64/toobamp_1.1.63_amd64.deb b/lv2/x86_64/toobamp_1.1.64_amd64.deb similarity index 57% rename from lv2/x86_64/toobamp_1.1.63_amd64.deb rename to lv2/x86_64/toobamp_1.1.64_amd64.deb index 5486602..91d5362 100644 Binary files a/lv2/x86_64/toobamp_1.1.63_amd64.deb and b/lv2/x86_64/toobamp_1.1.64_amd64.deb differ diff --git a/modules/SQLiteCpp b/modules/SQLiteCpp index 1df7688..0fef5b9 160000 --- a/modules/SQLiteCpp +++ b/modules/SQLiteCpp @@ -1 +1 @@ -Subproject commit 1df768817e68529fe47870f8e9913a47343a822d +Subproject commit 0fef5b93eab8dfe20c0b3d79783a83e806a48478 diff --git a/src/AlsaDriver.cpp b/src/AlsaDriver.cpp index 5df7144..195cec6 100644 --- a/src/AlsaDriver.cpp +++ b/src/AlsaDriver.cpp @@ -35,6 +35,7 @@ #include "PiPedalException.hpp" #include "DummyAudioDriver.hpp" #include "SchedulerPriority.hpp" +#include "CrashGuard.hpp" #include "CpuUse.hpp" @@ -1484,7 +1485,7 @@ namespace pipedal #endif } - std::jthread *audioThread = nullptr; + std::unique_ptr audioThread; bool audioRunning; bool block = false; @@ -1518,32 +1519,36 @@ namespace pipedal } while (frames > 0); return framesRead; } + protected: void ReadMidiData(uint32_t audioFrame) { AlsaMidiMessage message; midiEventCount = 0; - while(alsaSequencer->ReadMessage(message,0)) + while (alsaSequencer->ReadMessage(message, 0)) { size_t messageSize = message.size; - if (messageSize == 0) + if (messageSize == 0) { continue; } - if (midiEventMemoryIndex + messageSize >= this->midiEventMemory.size()) { + if (midiEventMemoryIndex + messageSize >= this->midiEventMemory.size()) + { continue; } - if (midiEventCount >= this->midiEvents.size()) { - midiEvents.resize(midiEventCount*2); + if (midiEventCount >= this->midiEvents.size()) + { + midiEvents.resize(midiEventCount * 2); } - // for now, prevent META event messages from propagating. - if (message.data[0] == 0xFF && message.size > 1) { + // for now, prevent META event messages from propagating. + if (message.data[0] == 0xFF && message.size > 1) + { continue; } MidiEvent *pEvent = midiEvents.data() + midiEventCount++; pEvent->time = audioFrame; - pEvent->size = messageSize; + pEvent->size = messageSize; pEvent->buffer = midiEventMemory.data() + midiEventMemoryIndex; memcpy( @@ -1551,11 +1556,10 @@ namespace pipedal message.data, message.size); midiEventMemoryIndex += messageSize; - } } - private: + private: long WriteBuffer(snd_pcm_t *handle, uint8_t *buf, size_t frames) { long framesRead; @@ -1592,6 +1596,8 @@ namespace pipedal throw PiPedalStateException("Unable to start ALSA capture."); } + CrashGuardLock crashGuardLock; + cpuUse.SetStartTime(cpuUse.Now()); while (true) { @@ -1759,7 +1765,7 @@ namespace pipedal } } - audioThread = new std::jthread([this]() + audioThread = std::make_unique([this]() { AudioThread(); }); } @@ -1773,8 +1779,7 @@ namespace pipedal terminateAudio(true); if (audioThread) { - this->audioThread->join(); - this->audioThread = 0; + this->audioThread = 0; // jthread joins. } Lv2Log::debug("Audio thread joined."); } @@ -1789,9 +1794,6 @@ namespace pipedal AlsaSequencer::ptr alsaSequencer; public: - - - virtual void SetAlsaSequencer(AlsaSequencer::ptr alsaSequencer) override { this->alsaSequencer = alsaSequencer; @@ -1822,7 +1824,7 @@ namespace pipedal { for (size_t i = 0; i < buffer.size(); ++i) { - // delete[] buffer[i]; + delete[] buffer[i]; buffer[i] = 0; } buffer.clear(); @@ -1887,6 +1889,51 @@ namespace pipedal snd_pcm_t *captureHandle = nullptr; snd_pcm_hw_params_t *playbackHwParams = nullptr; snd_pcm_hw_params_t *captureHwParams = nullptr; + + Finally ff_playbackHandle{ + [&playbackHandle]() + { + if (playbackHandle) + { + int rc = snd_pcm_close(playbackHandle); + if (rc < 0) + { + throw std::runtime_error("snd_pcm_close failed."); + } + playbackHandle = nullptr; + } + }}; + Finally ff_captureHandle{ + [&captureHandle]() + { + if (captureHandle) + { + int rc = snd_pcm_close(captureHandle); + if (rc < 0) + { + throw std::runtime_error("snd_pcm_close failed."); + } + + captureHandle = nullptr; + } + }}; + Finally ff_playbackHwParams{ + [&playbackHwParams]() + { + if (playbackHwParams) + { + snd_pcm_hw_params_free(playbackHwParams); + } + }}; + Finally ff_captureHwParams{ + [&captureHwParams]() + { + if (captureHwParams) + { + snd_pcm_hw_params_free(captureHwParams); + } + }}; + std::string alsaDeviceName = jackServerSettings.GetAlsaInputDevice(); bool result = false; @@ -2001,28 +2048,6 @@ namespace pipedal result = false; throw; } - if (playbackHwParams) - { - snd_pcm_hw_params_free(playbackHwParams); - playbackHwParams = nullptr; - } - - if (captureHwParams) - { - snd_pcm_hw_params_free(captureHwParams); - captureHwParams = nullptr; - } - - if (playbackHandle) - { - snd_pcm_close(playbackHandle); - playbackHandle = nullptr; - } - if (captureHandle) - { - snd_pcm_close(captureHandle); - captureHandle = nullptr; - } return result; } @@ -2176,4 +2201,9 @@ namespace pipedal } #endif } + + void FreeAlsaGlobals() + { + snd_config_update_free_global(); // to get a clean Valgrind report. + } } // namespace diff --git a/src/AlsaDriver.hpp b/src/AlsaDriver.hpp index d9a6aa2..c579813 100644 --- a/src/AlsaDriver.hpp +++ b/src/AlsaDriver.hpp @@ -40,5 +40,7 @@ namespace pipedal { // test only. void AlsaFormatEncodeDecodeTest(AudioDriverHost*driverHost); void MidiDecoderTest(); + + void FreeAlsaGlobals(); // for valgrind. Free the Alsa configuration cache. } diff --git a/src/AudioHost.cpp b/src/AudioHost.cpp index a255b9f..3cdb7cc 100644 --- a/src/AudioHost.cpp +++ b/src/AudioHost.cpp @@ -931,6 +931,15 @@ private: uint8_t cmd = (uint8_t)(event.buffer[0] & 0xF0); bool isNote = cmd == 0x90 && event.buffer[2] != 0; // note on with velocity > 0. bool isControl = cmd == 0xB0; + if (isControl) + { + // ignore bank select, and CC LSB values. + uint8_t cc1 = (uint8_t)(event.buffer[1]); + if (cc1 == 0 || (cc1 >= 32 && cc1 < 64)) + { + isControl = false; + } + } if (isNote || isControl) { MidiNotifyBody notifyBody (event.buffer[0],event.buffer[1], event.buffer[2]); @@ -1502,8 +1511,10 @@ public: if (property != nullptr && value != nullptr && property->type == uris.atom_URID) { LV2_URID propertyUrid = ((LV2_Atom_URID *)property)->body; - this->pNotifyCallbacks->OnPatchSetReply(instanceId, propertyUrid, value); - // this->pNotifyCallbacks->OnNotifyMaybeLv2StateChanged(instanceId); + if (this->pNotifyCallbacks) + { + this->pNotifyCallbacks->OnPatchSetReply(instanceId, propertyUrid, value); + } } } } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7055495..95faca4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -206,6 +206,10 @@ else() endif() set (PIPEDAL_SOURCES + CrashGuard.cpp CrashGuard.hpp + ModTemplateGenerator.cpp ModTemplateGenerator.hpp + WebServerMod.cpp WebServerMod.hpp + ModGui.cpp ModGui.hpp PipewireInputStream.cpp PipewireInputStream.hpp AudioFiles.cpp AudioFiles.hpp AudioFileMetadataReader.cpp AudioFileMetadataReader.hpp @@ -414,6 +418,8 @@ add_executable(AuxInTest add_executable(pipedaltest testMain.cpp + + ModGuiTest.cpp PipewireInputStreamTest.cpp AudioFilesTest.cpp @@ -762,6 +768,7 @@ add_executable(pipedal_latency_test DummyAudioDriver.cpp DummyAudioDriver.hpp JackConfiguration.hpp JackConfiguration.cpp JackServerSettings.hpp JackServerSettings.cpp + CrashGuard.cpp CrashGuard.hpp CpuUse.hpp CpuUse.cpp ) diff --git a/src/CrashGuard.cpp b/src/CrashGuard.cpp new file mode 100644 index 0000000..472bf47 --- /dev/null +++ b/src/CrashGuard.cpp @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include "CrashGuard.hpp" +#include "ofstream_synced.hpp" +#include +#include + +using namespace pipedal; +namespace fs = std::filesystem; + +int CrashGuard::crashCount = 0; +std::mutex CrashGuard::mutex; +std::filesystem::path CrashGuard::fileName; +int64_t CrashGuard::depth = 0; + +void CrashGuard::SetCrashGuardFileName(const std::filesystem::path &path) +{ + CrashGuard::fileName = path; + + { + std::ifstream f{path}; + if (f.is_open()) + { + f >> crashCount; + } + if (crashCount > 0) { + Lv2Log::info("CrashGuard: Detected previous crash, count = %d", (int)crashCount); + } + } +} +bool CrashGuard::HasCrashed() +{ + return crashCount > 4; +} +void CrashGuard::ClearCrashFlag() +{ + crashCount = 0; + try + { + if (fs::exists(fileName)) + { + fs::remove(fileName); + } + } + catch (const std::exception &ignored) + { + } +} + +void CrashGuard::EnterCrashGuardZone() +{ + std::lock_guard lock{mutex}; + + if (depth++ == 0) + { + if (!fileName.empty()) { + ofstream_synced f{fileName}; + f << (crashCount + 1) << std::endl; + } + } +} +void CrashGuard::LeaveCrashGuardZone() +{ + std::lock_guard lock{mutex}; + if (--depth == 0) + { + try + { + if (!fileName.empty() && fs::exists(fileName)) + { + fs::remove(fileName); + } + } + catch (const std::exception &ignored) + { + } + } +} diff --git a/src/CrashGuard.hpp b/src/CrashGuard.hpp new file mode 100644 index 0000000..d62839d --- /dev/null +++ b/src/CrashGuard.hpp @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#pragma once + +#include +#include +#include + +namespace pipedal +{ + + /** + * @brief Crash protection. + * + * Detect the situation where PiPedal has crashed multiple times due to SIG_SEGVS caused by a plugin. + * + * The basic premise: mark segments of code as "crash protected". If Pipedal shuts down without exiting the + * crash guard zone for some fixed number of occasions (3?), then set a flag that will prevent + * loading of the default plugin when PiPedal next loads. Since PiPedal restarts indefinitely while + * running as a service, this allows users to recover after they have committed a plugin that crashes + * into their current preset. + * + */ + + class CrashGuard + { + public: + static void SetCrashGuardFileName(const std::filesystem::path &path); + static bool HasCrashed(); + static void ClearCrashFlag(); + + static void EnterCrashGuardZone(); + static void LeaveCrashGuardZone(); + + private: + static int crashCount; + static std::mutex mutex; + static std::filesystem::path fileName; + static int64_t depth; + }; + + class CrashGuardLock { + public: + CrashGuardLock() { + CrashGuard::EnterCrashGuardZone(); + } + ~CrashGuardLock() { + CrashGuard::LeaveCrashGuardZone(); + } + CrashGuardLock(const CrashGuardLock &) = delete; + CrashGuardLock &operator=(const CrashGuardLock &) = delete; + CrashGuardLock(CrashGuardLock &&) = delete; + CrashGuardLock &operator=(CrashGuardLock &&) = delete; + }; +}; \ No newline at end of file diff --git a/src/DummyAudioDriver.cpp b/src/DummyAudioDriver.cpp index 37b25bb..a03e034 100644 --- a/src/DummyAudioDriver.cpp +++ b/src/DummyAudioDriver.cpp @@ -38,6 +38,7 @@ #include #include "ss.hpp" #include "SchedulerPriority.hpp" +#include "CrashGuard.hpp" #include "CpuUse.hpp" @@ -287,6 +288,7 @@ namespace pipedal bool ok = true; + CrashGuardLock crashGuardLock; while (true) { diff --git a/src/Lv2Effect.cpp b/src/Lv2Effect.cpp index 6c0b47c..740a59f 100644 --- a/src/Lv2Effect.cpp +++ b/src/Lv2Effect.cpp @@ -100,7 +100,7 @@ Lv2Effect::Lv2Effect( lilv_node_free(uriNode); { AutoLilvNode bundleUri = lilv_plugin_get_bundle_uri(pPlugin); - char *bundleUriString = lilv_file_uri_parse(bundleUri.AsUri().c_str(), nullptr); + char* bundleUriString = lilv_file_uri_parse(bundleUri.AsUri().c_str(), nullptr); std::string storagePath = pHost_->GetPluginStoragePath(); @@ -108,7 +108,7 @@ Lv2Effect::Lv2Effect( pHost_->GetMapFeature().GetMap(), logFeature.GetLog(), bundleUriString, - storagePath); + storagePath); mapPathFeature.Prepare(&(pHost_->GetMapFeature())); mapPathFeature.SetPluginStoragePath(pHost_->GetPluginStoragePath()); @@ -117,11 +117,12 @@ Lv2Effect::Lv2Effect( const auto &fileProperties = info_->piPedalUI()->fileProperties(); for (const auto &fileProperty : fileProperties) { - if (!fileProperty->resourceDirectory().empty()) - { - mapPathFeature.AddResourceFileMapping({makeAbsolutePath(fileProperty->resourceDirectory(), bundleUriString), - makeAbsolutePath(fileProperty->directory(), pHost_->GetPluginStoragePath())}); - } + fs::path targetPath = fileProperty->directory() / std::filesystem::path(bundleUriString).parent_path().filename(); + mapPathFeature.AddResourceFileMapping({ + bundleUriString, + storagePath / targetPath, + fileProperty->fileTypes() + }); } } @@ -243,14 +244,41 @@ Lv2Effect::Lv2Effect( // now that we've loaded the preset, clear the uri, and save new state // Why? because lilv doesn't provide facilities for reading state. pedalboardItem.lv2State(this->stateInterface->Save()); + RestoreState(pedalboardItem); // reload it with OUR map/unmap handling. pedalboardItem.lilvPresetUri(""); } else { - RestoreState(pedalboardItem); + if (!RestoreState(pedalboardItem)) + { + if (info->hasDefaultState()) + { + // restore the default state. + try + { + // REsTORE from LV2_STATE__state default state. + AutoLilvNode pluginNode = lilv_new_uri(pWorld, info->uri().c_str()); + LilvState *pState = lilv_state_new_from_world(pWorld, pHost->GetMapFeature().GetMap(), pluginNode); + if (pState) + { + if (this->stateInterface) + { + this->stateInterface->RestoreState(pState); + } + lilv_state_free(pState); + } + pedalboardItem.lv2State(this->stateInterface->Save()); + RestoreState(pedalboardItem); // do it with OUR map/unmap file handling. + } + catch (const std::exception &e) + { + Lv2Log::warning(SS("Failed to restore default state for " << info->name() << ": " << e.what())); + } + } + } } } -void Lv2Effect::RestoreState(PedalboardItem &pedalboardItem) +bool Lv2Effect::RestoreState(PedalboardItem &pedalboardItem) { // Restore state if present. if (this->stateInterface) @@ -260,20 +288,18 @@ void Lv2Effect::RestoreState(PedalboardItem &pedalboardItem) if (pedalboardItem.lv2State().isValid_) { this->stateInterface->Restore(pedalboardItem.lv2State()); + return true; } - else - { - // set the state to default state. - auto savedState = this->stateInterface->Save(); - pedalboardItem.lv2State(savedState); - } + return false; } catch (const std::exception &e) { std::string name = pedalboardItem.pluginName(); Lv2Log::warning(SS(name << ": " << e.what())); + return false; } } + return true; } void Lv2Effect::ConnectControlPorts() @@ -1116,4 +1142,3 @@ void Lv2Effect::SetPathPatchProperty(const std::string &propertyUri, const std:: { mainThreadPathProperties[propertyUri] = jsonAtom; } - diff --git a/src/Lv2Effect.hpp b/src/Lv2Effect.hpp index 6fe1117..0dd80e3 100644 --- a/src/Lv2Effect.hpp +++ b/src/Lv2Effect.hpp @@ -61,7 +61,7 @@ namespace pipedal FileBrowserFilesFeature fileBrowserFilesFeature; std::unique_ptr stateInterface; - void RestoreState(PedalboardItem&pedalboardItem); + bool RestoreState(PedalboardItem&pedalboardItem); LogFeature logFeature; std::map patchPropertyPrototypes; diff --git a/src/Lv2HostLeakTest.cpp b/src/Lv2HostLeakTest.cpp index d5738b6..a36a5a6 100644 --- a/src/Lv2HostLeakTest.cpp +++ b/src/Lv2HostLeakTest.cpp @@ -36,7 +36,7 @@ TEST_CASE( "PluginHost memory leak", "[lv2host_leak][Build][Dev]" ) { { PluginHost host; - host.Load("/usr/lib/lv2:/usr/local/lib/lv2:/usr/modep/lv2"); + host.LoadLilv("/usr/lib/lv2:/usr/local/lib/lv2:/usr/modep/lv2"); } MemStats finalMemory = GetMemStats(); diff --git a/src/Lv2Pedalboard.cpp b/src/Lv2Pedalboard.cpp index 5fa82e0..df6d0bd 100644 --- a/src/Lv2Pedalboard.cpp +++ b/src/Lv2Pedalboard.cpp @@ -27,6 +27,7 @@ #include "AudioHost.hpp" #include "Lv2EventBufferWriter.hpp" #include "Lv2Log.hpp" +#include "CrashGuard.hpp" using namespace pipedal; @@ -401,6 +402,8 @@ void Lv2Pedalboard::UpdateAudioPorts() void Lv2Pedalboard::Activate() { + CrashGuardLock crashGuardLock; + for (int i = 0; i < this->effects.size(); ++i) { this->realtimeEffects[i]->Activate(); diff --git a/src/MapFeature.hpp b/src/MapFeature.hpp index 7c9c38b..1481e8e 100644 --- a/src/MapFeature.hpp +++ b/src/MapFeature.hpp @@ -65,5 +65,8 @@ namespace pipedal { const LV2_URID_Map *GetMap() const { return ↦} LV2_URID_Map *GetMap() { return ↦} + const LV2_URID_Unmap *GetUnmap() const { return &unmap;} + LV2_URID_Unmap *GetUnmap() { return &unmap;} + }; } diff --git a/src/MapPathFeature.cpp b/src/MapPathFeature.cpp index aedd4e7..ca56020 100644 --- a/src/MapPathFeature.cpp +++ b/src/MapPathFeature.cpp @@ -22,9 +22,12 @@ #include #include "json.hpp" #include +#include "PluginHost.hpp" +#include "util.hpp" using namespace pipedal; +namespace fs = std::filesystem; MapPathFeature::MapPathFeature() { @@ -69,6 +72,27 @@ void MapPathFeature::FnFreePath(LV2_State_Free_Path_Handle handle, char* path) return ((MapPathFeature *)handle)->AbsolutePath(abstract_path); } +static std::string CreateMappathLink(const fs::path&sourceFile, const fs::path &resourceDirectory, const fs::path &targetDirectory) +{ + try { + fs::path targetFile = targetDirectory / sourceFile.filename(); + if (fs::exists(targetFile)) { + return targetFile; + } + fs::create_directories(targetDirectory); + fs::create_symlink(sourceFile,targetFile); + return targetFile; + }catch (const std::exception &e) { + Lv2Log::error(SS("Failed to convert resource file to media file. " + << sourceFile + << " -> " + << targetDirectory + << " (" << e.what() << ")" + )); + return sourceFile; + } +} + char *MapPathFeature::AbsolutePath(const char *abstract_path) { if (strlen(abstract_path) == 0) @@ -76,7 +100,27 @@ char *MapPathFeature::AbsolutePath(const char *abstract_path) return strdup(""); } std::filesystem::path t (abstract_path); + std::string extension = t.extension(); if (t.is_absolute()) { + + // Handle bundle files, mapping them to the storage path for this plugin. + for (const auto&mapping: this->resourceFileMappings) + { + if (IsSubdirectory(t,mapping.resourcePath)) + { + bool allowed = false; + for (const auto&fileType: mapping.fileTypes) { + if (fileType.IsValidExtension(extension)) { + allowed = true; + break; + } + } + if (allowed) { + std::string result = CreateMappathLink(t,mapping.resourcePath, mapping.storagePath); + return strdup(result.c_str()); + } + } + } return strdup(abstract_path); } std::filesystem::path result = storagePath / t; diff --git a/src/MapPathFeature.hpp b/src/MapPathFeature.hpp index 655c42f..bd8062d 100644 --- a/src/MapPathFeature.hpp +++ b/src/MapPathFeature.hpp @@ -21,17 +21,24 @@ #include "lv2/state/state.h" #include #include "MapFeature.hpp" +#include +#include +#include +#include "PiPedalUI.hpp" namespace pipedal { + class Lv2PluginInfo; struct ResourceFileMapping { - std::string resourcePath; // absolute path of the resource directory. - std::string storagePath; // absolute path of where resource directory files get placed. + std::filesystem::path resourcePath; // absolute path of the resource directory. + std::filesystem::path storagePath; // absolute path of where resource directory files get placed. + std::vector fileTypes; }; class MapPathFeature { public: MapPathFeature(); + void Prepare(MapFeature* map); void SetPluginStoragePath(const std::string&path) { storagePath = path;} @@ -46,6 +53,8 @@ namespace pipedal const LV2_Feature*GetMakePathFeature() { return &makePathFeature;} const LV2_Feature*GetFreePathFeature() { return &freePathFeature;} private: + + std::shared_ptr pluginInfo; char *AbsolutePath(const char *abstract_path); static char *FnAbsolutePath(LV2_State_Map_Path_Handle handle, const char *abstract_path); diff --git a/src/MimeTypes.cpp b/src/MimeTypes.cpp index e98b804..8940182 100644 --- a/src/MimeTypes.cpp +++ b/src/MimeTypes.cpp @@ -22,6 +22,7 @@ #include "ss.hpp" #include #include +#include "util.hpp" using namespace pipedal; @@ -30,7 +31,16 @@ static std::string empty; const std::string& MimeTypes::MimeTypeFromExtension(const std::string &extension) const { auto iter = extensionToMimeType.find(extension); - if (iter == extensionToMimeType.end()) return empty; + if (iter == extensionToMimeType.end()) + { + std::string lowerExt = ToLower(extension); + auto iter2 = extensionToMimeType.find(lowerExt); + if (iter2 != extensionToMimeType.end()) + { + return iter2->second; + } + return empty; + } return iter->second; } diff --git a/src/ModFileTypes.cpp b/src/ModFileTypes.cpp index 05a24cb..5d94d55 100644 --- a/src/ModFileTypes.cpp +++ b/src/ModFileTypes.cpp @@ -95,6 +95,7 @@ ModFileTypes::ModFileTypes(const std::string &fileTypes) if (wellKnownType) { rootDirectories_.push_back(type); + modFileTypes_.push_back(type); } else { diff --git a/src/ModFileTypes.hpp b/src/ModFileTypes.hpp index a0f5673..70ad28a 100644 --- a/src/ModFileTypes.hpp +++ b/src/ModFileTypes.hpp @@ -32,12 +32,14 @@ namespace pipedal ModFileTypes(const std::string &fileTypes); const std::vector &rootDirectories() const { return rootDirectories_; } std::vector &rootDirectories() { return rootDirectories_; } + const std::vector &modFileTypes() const { return modFileTypes_; } const std::vector &fileTypes() const { return fileTypes_; } static constexpr const char *DEFAULT_FILE_TYPES = "audio,nammodel,mlmodel,sf2,sfz,midisong,midiclip,*"; // (everything) private: std::vector rootDirectories_; + std::vector modFileTypes_; // e.g. "audio", "nammodel", etc. std::vector fileTypes_; public: @@ -55,6 +57,7 @@ namespace pipedal const std::vector fileTypesX; // mixture of .ext and mime types. Use fileExtensions instead. const std::set fileExtensions; + }; static const std::vector &ModDirectories(); diff --git a/src/ModGui.cpp b/src/ModGui.cpp new file mode 100644 index 0000000..0ec6915 --- /dev/null +++ b/src/ModGui.cpp @@ -0,0 +1,531 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + + + #include "ModGui.hpp" + #include "MapFeature.hpp" + #include "PluginHost.hpp" + #include "Finally.hpp" + #include "lv2/atom/atom.h" + + +#define MOD_GUI_PREFIX "http://moddevices.com/ns/modgui#" +#define MOD_GUI__gui (MOD_GUI_PREFIX "gui") +#define MOD_GUI__modgui (MOD_GUI_PREFIX "modgui") +#define MOD_GUI__resourcesDirectory (MOD_GUI_PREFIX "resourcesDirectory") +#define MOD_GUI__iconTemplate (MOD_GUI_PREFIX "iconTemplate") +#define MOD_GUI__settingsTemplate (MOD_GUI_PREFIX "settingsTemplate") +#define MOD_GUI__javascript (MOD_GUI_PREFIX "javascript") +#define MOD_GUI__stylesheet (MOD_GUI_PREFIX "stylesheet") +#define MOD_GUI__screenshot (MOD_GUI_PREFIX "screenshot") +#define MOD_GUI__thumbnail (MOD_GUI_PREFIX "thumbnail") +#define MOD_GUI__discussionURL (MOD_GUI_PREFIX "discussionURL") +#define MOD_GUI__documentation (MOD_GUI_PREFIX "documentation") +#define MOD_GUI__brand (MOD_GUI_PREFIX "brand") +#define MOD_GUI__label (MOD_GUI_PREFIX "label") +#define MOD_GUI__model (MOD_GUI_PREFIX "model") +#define MOD_GUI__panel (MOD_GUI_PREFIX "panel") +#define MOD_GUI__color (MOD_GUI_PREFIX "color") +#define MOD_GUI__knob (MOD_GUI_PREFIX "knob") +#define MOD_GUI__port (MOD_GUI_PREFIX "port") +#define MOD_GUI__monitoredOutputs (MOD_GUI_PREFIX "monitoredOutputs") + + + +using namespace pipedal; + +ModGuiUris::ModGuiUris(LilvWorld *pWorld, MapFeature &mapFeature) +{ + mod_gui__gui = lilv_new_uri(pWorld, MOD_GUI__gui); + mod_gui__modgui = lilv_new_uri(pWorld, MOD_GUI__modgui); + mod_gui__resourceDirectory = lilv_new_uri(pWorld, MOD_GUI__resourcesDirectory); + mod_gui__iconTemplate = lilv_new_uri(pWorld, MOD_GUI__iconTemplate); + mod_gui__settingsTemplate = lilv_new_uri(pWorld, MOD_GUI__settingsTemplate); + mod_gui__javascript = lilv_new_uri(pWorld, MOD_GUI__javascript); + mod_gui__stylesheet = lilv_new_uri(pWorld, MOD_GUI__stylesheet); + mod_gui__screenshot = lilv_new_uri(pWorld, MOD_GUI__screenshot); + mod_gui__thumbnail = lilv_new_uri(pWorld, MOD_GUI__thumbnail); + mod_gui__discussionURL = lilv_new_uri(pWorld, MOD_GUI__discussionURL); + mod_gui__documentation = lilv_new_uri(pWorld, MOD_GUI__documentation); + mod_gui__brand = lilv_new_uri(pWorld, MOD_GUI__brand); + mod_gui__label = lilv_new_uri(pWorld, MOD_GUI__label); + mod_gui__model = lilv_new_uri(pWorld, MOD_GUI__model); + mod_gui__panel = lilv_new_uri(pWorld, MOD_GUI__panel); + mod_gui__color = lilv_new_uri(pWorld, MOD_GUI__color); + mod_gui__knob = lilv_new_uri(pWorld, MOD_GUI__knob); + mod_gui__port = lilv_new_uri(pWorld, MOD_GUI__port); + mod_gui__monitoredOutputs = lilv_new_uri(pWorld, MOD_GUI__monitoredOutputs); +} + +static const char*NonNull(const char*string) { + return string ? string : ""; +} + + +ModGui::ModGui( + PluginHost *lv2Host, + const LilvPlugin *lilvPlugin, + const std::string &resourceDirectory, + const LilvNode * modGuiUrl) + : pluginUri_(lilv_node_as_uri(lilv_plugin_get_uri(lilvPlugin))), + resourceDirectory_(resourceDirectory) +{ + LilvWorld *pWorld = lv2Host->getWorld(); + ModGuiUris &modGuiUrids = *lv2Host->mod_gui_uris; + PluginHost::LilvUris &lilvUris = *lv2Host->lilvUris; + + AutoLilvNode modguiIcon = lilv_world_get(pWorld, modGuiUrl, modGuiUrids.mod_gui__iconTemplate, nullptr); + if (modguiIcon) { + this->iconTemplate_ = NonNull(lilv_file_uri_parse(lilv_node_as_string(modguiIcon), nullptr)); + } + + AutoLilvNode modguiSettingsTemplate = lilv_world_get(pWorld, modGuiUrl, modGuiUrids.mod_gui__settingsTemplate, nullptr); + if (modguiSettingsTemplate) { + this->settingsTemplate_ = NonNull(lilv_file_uri_parse(lilv_node_as_string(modguiSettingsTemplate), nullptr)); + } + + AutoLilvNode modguiJavascript = lilv_world_get(pWorld, modGuiUrl, modGuiUrids.mod_gui__javascript, nullptr); + if (modguiJavascript) { + this->javascript_ = NonNull(lilv_file_uri_parse(lilv_node_as_string(modguiJavascript), nullptr)); + } + + AutoLilvNode modguiStylesheet = lilv_world_get(pWorld, modGuiUrl, modGuiUrids.mod_gui__stylesheet, nullptr); + if (modguiStylesheet) { + this->stylesheet_ = NonNull(lilv_file_uri_parse(lilv_node_as_string(modguiStylesheet), nullptr)); + } + + AutoLilvNode modguiScreenshot = lilv_world_get(pWorld, modGuiUrl, modGuiUrids.mod_gui__screenshot, nullptr); + if (modguiScreenshot) { + this->screenshot_ = NonNull(lilv_file_uri_parse(lilv_node_as_string(modguiScreenshot), nullptr)); + } + + AutoLilvNode modguiThumbnail = lilv_world_get(pWorld, modGuiUrl, modGuiUrids.mod_gui__thumbnail, nullptr); + if (modguiThumbnail) { + this->thumbnail_ = NonNull(lilv_file_uri_parse(lilv_node_as_string(modguiThumbnail), nullptr)); + } + AutoLilvNode discussionUrl = lilv_world_get(pWorld, modGuiUrl, modGuiUrids.mod_gui__discussionURL, nullptr); + if (discussionUrl) { + this->discussionUrl_ = NonNull(lilv_file_uri_parse(lilv_node_as_string(discussionUrl), nullptr)); + } + + AutoLilvNode documentationUrl = lilv_world_get(pWorld, modGuiUrl, modGuiUrids.mod_gui__documentation, nullptr); + if (documentationUrl) { + this->documentationUrl_ = NonNull(lilv_file_uri_parse(lilv_node_as_string(documentationUrl), nullptr)); + } + + AutoLilvNode brand = lilv_world_get(pWorld, modGuiUrl, modGuiUrids.mod_gui__brand, nullptr); + if (brand) { + this->brand_ = NonNull(lilv_node_as_string(brand)); + } + + AutoLilvNode label = lilv_world_get(pWorld, modGuiUrl, modGuiUrids.mod_gui__label, nullptr); + if (label) { + this->label_ = NonNull(lilv_node_as_string(label)); + } + + AutoLilvNode model = lilv_world_get(pWorld, modGuiUrl, modGuiUrids.mod_gui__model, nullptr); + if (model) { + this->model_ = NonNull(lilv_node_as_string(model)); + } + + AutoLilvNode panel = lilv_world_get(pWorld, modGuiUrl, modGuiUrids.mod_gui__panel, nullptr); + if (panel) { + this->panel_ = NonNull(lilv_node_as_string(panel)); + } + + AutoLilvNode color = lilv_world_get(pWorld, modGuiUrl, modGuiUrids.mod_gui__color, nullptr); + if (color) { + this->color_ = NonNull(lilv_node_as_string(color)); + } + + AutoLilvNode knob = lilv_world_get(pWorld, modGuiUrl, modGuiUrids.mod_gui__knob, nullptr); + if (knob) { + this->knob_ = NonNull(lilv_node_as_string(knob)); + } + + AutoLilvNodes ports = lilv_world_find_nodes(pWorld, modGuiUrl, modGuiUrids.mod_gui__port, nullptr); + if (ports) { + LILV_FOREACH(nodes, it, ports.Get()) { + AutoLilvNode port_node = lilv_nodes_get(ports, it); + this->ports_.push_back(ModGuiPort(lv2Host,port_node)); + } + } + std::sort(this->ports_.begin(), this->ports_.end(), + [](const ModGuiPort &p1, const ModGuiPort &p2) { + return p1.index() < p2.index(); + }); + AutoLilvNodes monitoredOutputs = lilv_world_find_nodes(pWorld, modGuiUrl, modGuiUrids.mod_gui__monitoredOutputs, nullptr); + if (monitoredOutputs) { + LILV_FOREACH(nodes, it, monitoredOutputs.Get()) { + AutoLilvNode monitoredOutput = lilv_nodes_get(monitoredOutputs.Get(), it); + AutoLilvNode symbol = lilv_world_get(pWorld, monitoredOutput, lilvUris.lv2core__symbol, nullptr); + if (symbol) { + this->monitoredOutputs_.push_back(NonNull(lilv_node_as_string(symbol))); + } + } + } +} + + +ModGui::ptr ModGui::Create(PluginHost *lv2Host, const LilvPlugin *lilvPlugin) +{ + + LilvWorld *pWorld = lv2Host->getWorld(); + ModGuiUris &modGuiUrids = *(lv2Host->mod_gui_uris); + + AutoLilvNode modGuiUri; + std::string resourceDirectory; + + AutoLilvNodes modGuiNodes = lilv_plugin_get_value(lilvPlugin,modGuiUrids.mod_gui__gui); + + if (!modGuiNodes) { + return nullptr; // no mod gui found. + } + LILV_FOREACH(nodes, it, modGuiNodes.Get()) { + AutoLilvNode node = lilv_nodes_get(modGuiNodes.Get(), it); + + AutoLilvNode resourceDir = lilv_world_get(pWorld, node, modGuiUrids.mod_gui__resourceDirectory,nullptr); + if (resourceDir) { + resourceDirectory = lilv_file_uri_parse(lilv_node_as_string(resourceDir),nullptr); + modGuiUri = lilv_node_duplicate(node); + } else { + resourceDirectory.clear(); + modGuiUri.Free(); + } + } + if (!modGuiUri) { + return nullptr; // no mod gui found. + } + + + return std::shared_ptr(new ModGui(lv2Host, lilvPlugin, resourceDirectory, modGuiUri.Get())); +} + + +ModGuiPort::ModGuiPort(PluginHost *lv2Host, const LilvNode *portNode) +{ + LilvWorld *pWorld = lv2Host->getWorld(); + PluginHost::LilvUris &lilvUris = *lv2Host->lilvUris; + + this->index_ = lilv_node_as_int(lilv_world_get(pWorld, portNode, lilvUris.lv2core__index, nullptr)); + this->symbol_ = NonNull(lilv_node_as_string(lilv_world_get(pWorld, portNode, lilvUris.lv2core__symbol, nullptr))); + this->name_ = NonNull(lilv_node_as_string(lilv_world_get(pWorld, portNode, lilvUris.lv2core__name, nullptr))); +} + +static json_variant UnitsObj(const std::string &label, const std::string &render, const std::string &symbol) +{ + json_variant obj = json_variant::make_object(); + obj["label"] = label; + obj["render"] = render; + obj["symbol"] = symbol; + return obj; +} + + +static std::map unitsMap +{ + {Units::none, UnitsObj("","%f","")}, + {Units::unknown, UnitsObj("","%f","")}, + + {Units::s, UnitsObj("seconds", "%f s", "s")}, + {Units::ms, UnitsObj("milliseconds", "%f ms", "ms")}, + {Units::min, UnitsObj("minutes", "%f mins", "min")}, + {Units::bar, UnitsObj("bars", "%f bars", "bars")}, + {Units::beat, UnitsObj("beats", "%f beats", "beats")}, + {Units::frame, UnitsObj("audio frames", "%f frames", "frames")}, + {Units::m, UnitsObj("metres", "%f m", "m")}, + {Units::cm, UnitsObj("centimetres", "%f cm", "cm")}, + {Units::mm, UnitsObj("millimetres", "%f mm", "mm")}, + {Units::km, UnitsObj("kilometres", "%f km", "km")}, + {Units::inch, UnitsObj("inches", "\"%f\"", "in")}, + {Units::mile, UnitsObj("miles", "%f mi", "mi")}, + {Units::db, UnitsObj("decibels", "%f dB", "dB")}, + {Units::pc, UnitsObj("percent", "%f%%", "%")}, + {Units::coef, UnitsObj("coefficient", "* %f", "*")}, + {Units::hz, UnitsObj("hertz", "%f Hz", "Hz")}, + {Units::khz, UnitsObj("kilohertz", "%f kHz", "kHz")}, + {Units::mhz, UnitsObj("megahertz", "%f MHz", "MHz")}, + {Units::bpm, UnitsObj("beats per minute", "%f BPM", "BPM")}, + {Units::oct, UnitsObj("octaves", "%f octaves", "oct")}, + {Units::cent, UnitsObj("cents", "%f ct", "ct")}, + {Units::semitone12TET, UnitsObj("semitones", "%f semi", "semi")}, + {Units::degree, UnitsObj("degrees", "%f°", "°")}, + {Units::midiNote, UnitsObj("MIDI note", "MIDI note %d", "note")}, +}; + +static json_variant UnitsToJsonVariant(Units units) +{ + if (unitsMap.find(units) != unitsMap.end()) + { + return unitsMap[units]; + } + return unitsMap[Units::none]; +} + +template +static inline json_variant MakeJsonArray(const std::vector &vec) +{ + json_variant array = json_variant::make_array(); + auto &arrayRef = *array.as_array(); + for (const auto &item : vec) + { + json_variant itemVariant = json_variant(item); + arrayRef.push_back(itemVariant); + } + return array; +} + +static std::string AtomTypeToRangesType(const std::string& atomType) +{ + if (atomType == LV2_ATOM__Path || + atomType == LV2_ATOM__String || + atomType == LV2_ATOM__URI || + atomType == LV2_ATOM__URID) + { + return "s"; + } + if (atomType == LV2_ATOM__Float) + { + return "f"; + } + if (atomType == LV2_ATOM__Double) + { + return "d"; + } + if (atomType == LV2_ATOM__Long) + { + return "l"; + } + if (atomType == LV2_ATOM__Vector) + { + return "v"; + } + return "?"; +} +json_variant pipedal::MakeModGuiTemplateData( + std::shared_ptr pluginInfo) +{ + json_variant context = json_variant::make_object(); + auto &contextObj = *(context.as_object()); + + auto modGui = pluginInfo->modGui(); + + contextObj["brand"] = modGui->brand(); + contextObj["label"] = modGui->label(); + contextObj["model"] = modGui->model(); + contextObj["panel"] = modGui->panel(); + contextObj["color"] = modGui->color(); + contextObj["knob"] = modGui->knob(); + + json_variant controls = json_variant::make_array(); + auto &controlArray = *controls.as_array(); + size_t ix = 0; + for (const auto& modGuiPort : modGui->ports()) + { + const Lv2PortInfo &pluginPort = pluginInfo->getPort(modGuiPort.symbol()); + if (!pluginPort.is_control_port() || !pluginPort.is_input() || pluginPort.not_on_gui()) + { + continue; // only include control ports + } + json_variant control = json_variant::make_object(); + auto &portObj = *control.as_object(); + portObj["index"] = double(pluginPort.index()); + portObj["symbol"] = pluginPort.symbol(); + portObj["name"] = modGuiPort.name(); + portObj["comment"] = pluginPort.comment(); + + portObj["units"] = UnitsToJsonVariant(pluginPort.units()); + + { + json_variant ranges = json_variant::make_object(); + ranges["minimum"] = SS(pluginPort.min_value()); + ranges["maximum"] = SS(pluginPort.max_value()); + ranges["default"] = SS(pluginPort.default_value()); + portObj["ranges"] = json_variant::make_array(); + } + json_variant scalePoints = json_variant::make_array(); + auto &scalePointsArray = *scalePoints.as_array(); + for (const auto &scalePoint: pluginPort.scale_points()) + { + json_variant scalePointObj = json_variant::make_object(); + scalePointObj["valid"] = true; + scalePointObj["label"] = scalePoint.label(); + scalePointObj["value"] = SS(scalePoint.value()); + scalePointsArray.push_back(scalePointObj); + } + portObj["scalePoints"] = scalePoints; + + controlArray.push_back(control); + } + contextObj["controls"] = controls; + + json_variant effect = json_variant::make_object(); + contextObj["effect"] = effect; + + json_variant ports = json_variant::make_object(); + (*effect.as_object())["ports"] = ports; + + json_variant audio = json_variant::make_object(); + (*ports.as_object())["audio"] = audio; + + json_variant audio_input = json_variant::make_array(); + (*audio.as_object())["input"] = audio_input; + + json_variant audio_output = json_variant::make_array(); + (*audio.as_object())["output"] = audio_output; + json_variant midi = json_variant::make_object(); + (*ports.as_object())["midi"] = midi; + + json_variant midi_input = json_variant::make_array(); + (*midi.as_object())["input"] = midi_input; + json_variant midi_output = json_variant::make_array(); + (*midi.as_object())["output"] = midi_output; + + json_variant cv = json_variant::make_object(); + (*ports.as_object())["cv"] = cv; + + json_variant cv_input = json_variant::make_array(); + (*cv.as_object())["input"] = cv_input; + json_variant cv_output = json_variant::make_array(); + (*cv.as_object())["output"] = cv_output; + + { + json_variant parametersObj = json_variant::make_object(); + json_variant pathObj = json_variant::make_array(); + parametersObj["path"] = pathObj; + + for (const auto &patchProperty: pluginInfo->patchProperties()) + { + json_variant parameterObj = json_variant::make_object(); + parameterObj["valid"] = true; + parameterObj["readable"] = patchProperty.readable(); + parameterObj["writable"] = patchProperty.writable(); + parameterObj["uri"] = patchProperty.uri(); + parameterObj["label"] = patchProperty.label(); + parameterObj["type"] = patchProperty.type(); + + json_variant rangesObj = json_variant::make_object(); + rangesObj["type"] = AtomTypeToRangesType(patchProperty.type()); + rangesObj["atomType"] = patchProperty.type(); + parameterObj["ranges"] = rangesObj; + + parameterObj["comment"] = patchProperty.comment(); + parameterObj["shortName"] = patchProperty.shortName(); + + parameterObj["fileTypes"] = MakeJsonArray(patchProperty.fileTypes()); + parameterObj["supportedExtensions"] = MakeJsonArray(patchProperty.supportedExtensions()); + + if (patchProperty.type() == LV2_ATOM__Path && patchProperty.writable()) { + // Feed them no files; they will be added later. + json_variant filesObj = json_variant::make_array(); + json_variant fileObj = json_variant::make_object(); + fileObj["filetype"] = "{{filetype}}"; // magic tags that we will use to generate files at runtime. + fileObj["fullname"] = "{{fullname}}"; + fileObj["basename"] = "{{basename}}"; + filesObj.as_array()->push_back(fileObj); + parameterObj["files"] = filesObj; + + pathObj.as_array()->push_back(parameterObj); + } + + } + + effect["parameters"] = parametersObj; + + + } + + for (const auto &pluginPort : pluginInfo->ports()) + { + if (pluginPort->is_control_port()) + { + continue; // only include control ports + } + if (pluginPort->is_audio_port()) + { + json_variant audioPort = json_variant::make_object(); + auto &audioPortObj = *audioPort.as_object(); + audioPortObj["index"] = double(pluginPort->index()); + audioPortObj["symbol"] = pluginPort->symbol(); + audioPortObj["name"] = pluginPort->name(); + if (pluginPort->is_input()) + { + audio_input.as_array()->push_back(audioPort); + } + else + { + audio_output.as_array()->push_back(audioPort); + } + } + else if (pluginPort->is_atom_port()) + { + if (pluginPort->supports_midi()) + { + json_variant midiPort = json_variant::make_object(); + auto &midiPortObj = *midiPort.as_object(); + midiPortObj["index"] = double(pluginPort->index()); + midiPortObj["symbol"] = pluginPort->symbol(); + midiPortObj["name"] = pluginPort->name(); + if (pluginPort->is_input()) + { + midi_input.as_array()->push_back(midiPort); + } + else + { + midi_output.as_array()->push_back(midiPort); + } + } + } + /* else if plugin_port->is_cv_port()... */ + } + return context; +} + + +JSON_MAP_BEGIN(ModGuiPort) + JSON_MAP_REFERENCE(ModGuiPort,index) + JSON_MAP_REFERENCE(ModGuiPort,symbol) + JSON_MAP_REFERENCE(ModGuiPort,name) +JSON_MAP_END() + +JSON_MAP_BEGIN(ModGui) + JSON_MAP_REFERENCE(ModGui,pluginUri) + JSON_MAP_REFERENCE(ModGui,resourceDirectory) + JSON_MAP_REFERENCE(ModGui,iconTemplate) + JSON_MAP_REFERENCE(ModGui,settingsTemplate) + JSON_MAP_REFERENCE(ModGui,screenshot) + JSON_MAP_REFERENCE(ModGui,javascript) + JSON_MAP_REFERENCE(ModGui,stylesheet) + JSON_MAP_REFERENCE(ModGui,thumbnail) + JSON_MAP_REFERENCE(ModGui,discussionUrl) + JSON_MAP_REFERENCE(ModGui,documentationUrl) + JSON_MAP_REFERENCE(ModGui,brand) + JSON_MAP_REFERENCE(ModGui,label) + JSON_MAP_REFERENCE(ModGui,model) + JSON_MAP_REFERENCE(ModGui,panel) + JSON_MAP_REFERENCE(ModGui,color) + JSON_MAP_REFERENCE(ModGui,knob) + JSON_MAP_REFERENCE(ModGui,ports) + JSON_MAP_REFERENCE(ModGui,monitoredOutputs) +JSON_MAP_END() + diff --git a/src/ModGui.hpp b/src/ModGui.hpp new file mode 100644 index 0000000..bfa4f06 --- /dev/null +++ b/src/ModGui.hpp @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#pragma once + +#include "lv2/urid/urid.h" +#include +#include +#include +#include "AutoLilvNode.hpp" +#include "json.hpp" +#include "json_variant.hpp" + +namespace pipedal +{ + + class MapFeature; + class Lv2PluginInfo; + + class ModGuiUris + { + public: + ModGuiUris(LilvWorld *pWorld, MapFeature &mapFeature); + + AutoLilvNode mod_gui__gui; + AutoLilvNode mod_gui__modgui; + AutoLilvNode mod_gui__resourceDirectory; + AutoLilvNode mod_gui__iconTemplate; + AutoLilvNode mod_gui__settingsTemplate; + AutoLilvNode mod_gui__javascript; + AutoLilvNode mod_gui__stylesheet; + AutoLilvNode mod_gui__screenshot; + AutoLilvNode mod_gui__thumbnail; + AutoLilvNode mod_gui__discussionURL; + AutoLilvNode mod_gui__documentation; + AutoLilvNode mod_gui__brand; + AutoLilvNode mod_gui__label; + AutoLilvNode mod_gui__model; + AutoLilvNode mod_gui__panel; + AutoLilvNode mod_gui__color; + AutoLilvNode mod_gui__knob; + AutoLilvNode mod_gui__port; + AutoLilvNode mod_gui__monitoredOutputs; + }; + + class PluginHost; + + class ModGuiPort { + public: + ModGuiPort() = default; + ModGuiPort(PluginHost *lv2Host, const LilvNode *portNode); + private: + uint32_t index_ = 0; + std::string symbol_; + std::string name_; + public: + uint32_t index() const { return index_; } + void index(uint32_t value) { index_ = value; } + const std::string &symbol() const { return symbol_; } + void symbol(const std::string &value) { symbol_ = value; } + const std::string &name() const { return name_; } + void name(const std::string &value) { name_ = value; } + + DECLARE_JSON_MAP(ModGuiPort); + }; + + class ModGui + { + private: + ModGui(PluginHost *lv2Host, const LilvPlugin *lilvPlugin, const std::string &resourceDirectory, const LilvNode *modGuiNode); + + public: + using self = ModGui; + using ptr = std::shared_ptr; + static ptr Create(PluginHost *lv2Host, const LilvPlugin *lilvPlugin); + + ModGui() = default; + virtual ~ModGui() = default; + + ModGui(const ModGui &) = default; + ModGui &operator=(const ModGui &) = default; + ModGui(ModGui &&) = default; + ModGui &operator=(ModGui &&) = default; + + private: + std::string pluginUri_; + + std::string resourceDirectory_; + std::string iconTemplate_; + std::string settingsTemplate_; + std::string javascript_; + std::string stylesheet_; + std::string screenshot_; + std::string thumbnail_; + std::string discussionUrl_; + std::string documentationUrl_; + std::string brand_; + std::string label_; + std::string model_; + std::string panel_; + std::string color_; + std::string knob_; + std::vector ports_; + + std::vector monitoredOutputs_; + + public: + const std::string &pluginUri() const { return pluginUri_; } + void pluginUri(const std::string &value) { pluginUri_ = value; } + + const std::string &resourceDirectory() const { return resourceDirectory_; } + void resourceDirectory(const std::string &value) { resourceDirectory_ = value; } + const std::string &iconTemplate() const { return iconTemplate_; } + void iconTemplate(const std::string &value) { iconTemplate_ = value; } + const std::string &settingsTemplate() const { return settingsTemplate_; } + void settingsTemplate(const std::string &value) { settingsTemplate_ = value; } + const std::string &screenshot() const { return screenshot_; } + const std::string &javascript() const { return javascript_; } + void javascript(const std::string &value) { javascript_ = value; } + const std::string &stylesheet() const { return stylesheet_; } + void stylesheet(const std::string &value) { stylesheet_ = value; } + void screenshot(const std::string &value) { screenshot_ = value; } + const std::string &thumbnail() const { return thumbnail_; } + void thumbnail(const std::string &value) { thumbnail_ = value; } + const std::string &discussionUrl() const { return discussionUrl_; } + void discussionUrl(const std::string &value) { discussionUrl_ = value; } + const std::string &documentationUrl() const { return documentationUrl_; } + void documentationUrl(const std::string &value) { documentationUrl_ = value; } + const std::string &brand() const { return brand_; } + void brand(const std::string &value) { brand_ = value; } + const std::string &label() const { return label_; } + void label(const std::string &value) { label_ = value; } + const std::string &model() const { return model_; } + void model(const std::string &value) { model_ = value; } + const std::string &panel() const { return panel_; } + void panel(const std::string &value) { panel_ = value; } + + const std::string &color() const { return color_; } + void color(const std::string &value) { color_ = value; } + const std::string &knob() const { return knob_; } + void knob(const std::string &value) { knob_ = value; } + + const std::vector &ports() const { return ports_; } + std::vector &ports() { return ports_; } + void ports(const std::vector &value) { ports_ = value; } + + + const std::vector &monitoredOutputs() const { return monitoredOutputs_; } + std::vector &monitoredOutputs() { return monitoredOutputs_; } + void monitoredOutputs(const std::vector &value) { monitoredOutputs_ = value; } + + DECLARE_JSON_MAP(ModGui); + + }; + + json_variant MakeModGuiTemplateData( + std::shared_ptr pluginInfo + ); + +} \ No newline at end of file diff --git a/src/ModGuiTest.cpp b/src/ModGuiTest.cpp new file mode 100644 index 0000000..8ad0c0f --- /dev/null +++ b/src/ModGuiTest.cpp @@ -0,0 +1,117 @@ +// Copyright (c) 2025 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +#include "pch.h" +#include "catch.hpp" +#include +#include +#include +#include +#include +#include +#include "ModTemplateGenerator.hpp" + +#include "PiPedalModel.hpp" + +using namespace pipedal; +using namespace std; + +class PluginHostTest { + +public: + static void TestInit() + { + PiPedalModel model; + model.GetPluginHost().LoadLilv(); + const auto&plugins = model.GetPluginHost().GetPlugins(); + REQUIRE(!plugins.empty()); + size_t modGuicount = 0; + for (const auto&plugin : plugins) + { + REQUIRE(plugin != nullptr); + + if (plugin->modGui()) + { + modGuicount++; + const auto&modGui = plugin->modGui(); + REQUIRE(!modGui->iconTemplate().empty()); + REQUIRE(modGui->javascript().empty()); + REQUIRE(!modGui->stylesheet().empty()); + REQUIRE(!modGui->screenshot().empty()); + REQUIRE(!modGui->thumbnail().empty()); + } + } + } +}; + +TEST_CASE("ModGui Init Test", "[mod_gui_init]") +{ + PluginHostTest::TestInit(); +} + +TEST_CASE("ModGui Templates", "[mod_gui_templates]") +{ + + json_variant data = json_variant::make_object(); + data["name"] = "Test Plugin"; + data["uri"] = "http://example.com/test-plugin"; + data["version"] = "1.0.0"; + data["author"] = "Test Author"; + data["cn"] = "14323"; + json_variant items = json_variant::make_array(); + for (size_t i = 0; i < 3; ++i) + { + json_variant item = json_variant::make_object(); + item["label"] = "Item " + std::to_string(i + 1); + items.as_array()->push_back(item); + + } + data["items"] = items; + + data["inputs"] = json_variant::make_object(); + data["inputs"]["input1"] = "Input 1"; + data["inputs"]["input2"] = "Input 2"; + + data["_cn"] = "?cn=1234"; + data["_cns"] = "_toobamp.lv2_ToobAmp__1234"; + + std::string templateString = R"( +
+

{{name}}

+

URI: {{uri}}

+

Version: {{version}}

+

Author: {{author}}

+
    + {{#items}} +
  • {{label}}
  • + {{/items}} +
+

{{inputs.input1}}

+

{{inputs.input2}}

+

{{inputs.input3}}

+ Help Icon +
+ )"; + auto result = GenerateFromTemplateString(templateString, data); + + cout << result; + cout << endl; +} + + diff --git a/src/ModTemplateGenerator.cpp b/src/ModTemplateGenerator.cpp new file mode 100644 index 0000000..e259b19 --- /dev/null +++ b/src/ModTemplateGenerator.cpp @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include "ModTemplateGenerator.hpp" +#include +#include +#include "util.hpp" + +namespace fs = std::filesystem; + +namespace pipedal +{ + namespace impl + { + + static std::string trim(const std::string &str) + { + size_t first = str.find_first_not_of(" \t\n\r"); + if (first == std::string::npos) + return ""; + size_t last = str.find_last_not_of(" \t\n\r"); + return str.substr(first, (last - first + 1)); + } + + static bool variableEndsWithNumber(const std::string &variableName) + { + size_t pos = variableName.find_last_of("."); + if (pos == std::string::npos) + { + return false; + } + for (size_t i = pos + 1; i < variableName.length(); ++i) + { + if (!std::isdigit(variableName[i])) + { + return false; + } + } + // Check if the last character is a digit + return true; + } + void splitIndexedArrayVariable( + const std::string &variableName, + std::string &arrayName, + int64_t &arrayIndex) + { + size_t pos = variableName.find_last_of("."); + if (pos == std::string::npos) + { + throw std::logic_error("Variable name does not contain an index: " + variableName); + } + else + { + arrayName = variableName.substr(0, pos); + arrayIndex = std::stoll(variableName.substr(pos + 1)); + } + } + + class VariableContext + { + public: + VariableContext( + const json_variant &context, + VariableContext *parent = nullptr) + : context(context), parent(parent) + { + } + json_variant getVariable(const std::string &name) + { + std::vector parts = split(trim(name), '.'); + json_variant *current = &context; + for (const auto &part : parts) + { + if (current->is_object()) + { + auto ff = current->as_object()->find(part); + if (ff != current->as_object()->end()) + { + current = &ff->second; + continue; + } + } + if (parent) + { + return parent->getVariable(name); + } + else + { + return json_variant(json_null()); + } + } + return *current; + } + + private: + json_variant context; + VariableContext *parent; + }; + + static std::string GenerateTemplateFromString( + const std::string &content, + VariableContext &context) + { + + std::stringstream ss; + + size_t ix = 0; + size_t end = content.length(); + while (ix < end) + { + // copy content until we find a '{{' + char c = content[ix++]; + if (c != '{') + { + ss << c; + continue; + } + if (ix >= end) + { + break; + } + c = content[ix++]; + if (c != '{') + { + ss << '{'; + ss << c; + continue; + } + if (ix < end && content[ix] == '{') + { + // this is a '{{{', so we just copy it. + ++ix; // skip the third '{' + size_t endTagPos = content.find("}}}", ix); + if (endTagPos == std::string::npos) + { + throw std::runtime_error("Unmatched opening tag '{{{' in template."); + } + std::string variableString = content.substr(ix, endTagPos - ix); + ix = endTagPos + 3; // Move past the closing '}}}' + auto result = context.getVariable("_" + variableString); + if (result.is_null()) + { + result = ""; // Default to empty string if variable not found + } + if (!result.is_string()) + { + throw std::runtime_error("Variable '" + variableString + "' is not a string."); + } + ss << result.as_string(); + continue; + } + else + { + // Now we have a '{{' at position ix-2. + size_t endTagPos = content.find("}}", ix); + if (endTagPos == std::string::npos) + { + throw std::runtime_error("Unmatched opening tag '{{' in template."); + } + std::string variableString = content.substr(ix, endTagPos - ix); + ix = endTagPos + 2; // Move past the closing '}}' + if (!variableString.starts_with("#")) + { + // a straightforward variable substitution. + json_variant value = context.getVariable(variableString); + if (value.is_null()) + { + value = ""; // Default to empty string if variable not found + } + if (!value.is_string()) + { + throw std::runtime_error("Variable '" + variableString + "' is not a string."); + } + ss << value.as_string(); + } + else + { + // this is a looping construct. + std::string variableName = variableString.substr(1); + std::string endTag = SS("{{/" << variableName << "}}"); + size_t endTagPos = content.find(endTag, ix); + if (endTagPos == std::string::npos) + { + throw std::runtime_error("Unmatched opening tag for '" + variableString + "' in template."); + } + + std::string arrayContent = content.substr(ix, endTagPos - ix); + + if (variableEndsWithNumber(variableName)) + { + std::string arrayName; + int64_t arrayIndex = 0; + splitIndexedArrayVariable(variableName, arrayName, arrayIndex); + + json_variant array = context.getVariable(arrayName); + auto &arrayValue = *array.as_array(); + if (arrayIndex >= 0 && arrayIndex < static_cast(arrayValue.size())) + { + json_variant contextValue = arrayValue[(size_t)arrayIndex]; + // variable frame. + VariableContext itemContext{contextValue, &context}; + + ss << GenerateTemplateFromString( + arrayContent, + itemContext); + } + } + else + { + json_variant variable = context.getVariable(variableName); + if (variable.is_array()) + { + auto &arrayValue = *variable.as_array(); + + for (auto iter = arrayValue.begin(); iter != arrayValue.end(); ++iter) + { + // variable frame. + VariableContext itemContext{*iter, &context}; + + ss << GenerateTemplateFromString( + arrayContent, + itemContext); + } + } else if (variable.is_object()) + { + VariableContext itemContext(variable,&context); + ss << GenerateTemplateFromString( + arrayContent, + itemContext); + } + } + + ix = endTagPos + endTag.length(); // Move past the closing tag + } + } + } + return ss.str(); + } + } + using namespace impl; + + std::string GenerateFromTemplateString( + const std::string &templateString, + const json_variant &data) + { + VariableContext context{data, nullptr}; + return impl::GenerateTemplateFromString( + templateString, + context); + } + + std::string GenerateFromTemplateFile( + const std::filesystem::path &templateFilePath, + const json_variant &data) + { + std::string templateContent; + if (!fs::exists(templateFilePath)) + { + throw std::runtime_error("Template file not found: " + templateFilePath.string()); + } + std::ifstream file(templateFilePath); + if (!file.is_open()) + { + throw std::runtime_error("Failed to open template file: " + templateFilePath.string()); + } + templateContent.assign((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + file.close(); + + return GenerateFromTemplateString( + templateContent, + data); + } + +} \ No newline at end of file diff --git a/src/ModTemplateGenerator.hpp b/src/ModTemplateGenerator.hpp new file mode 100644 index 0000000..2c7f463 --- /dev/null +++ b/src/ModTemplateGenerator.hpp @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#pragma once + +#include +#include +#include "json_variant.hpp" + +namespace pipedal +{ + std::string GenerateFromTemplateString( + const std::string& templateString, + const json_variant& data + ); + + std::string GenerateFromTemplateFile( + const std::filesystem::path& templateFilePath, + const json_variant& data + ); +} \ No newline at end of file diff --git a/src/Pedalboard.cpp b/src/Pedalboard.cpp index ce694ab..a6f1119 100644 --- a/src/Pedalboard.cpp +++ b/src/Pedalboard.cpp @@ -115,6 +115,18 @@ const ControlValue* PedalboardItem::GetControlValue(const std::string&symbol) co return nullptr; } +bool Pedalboard::SetItemUseModUi(int64_t pedalItemId, bool enabled) +{ + PedalboardItem*item = GetItem(pedalItemId); + if (!item) return false; + if (item->useModUi() != enabled) + { + item->useModUi(enabled); + return true; + } + return false; + +} bool Pedalboard::SetItemEnabled(int64_t pedalItemId, bool enabled) { PedalboardItem*item = GetItem(pedalItemId); @@ -508,11 +520,13 @@ JSON_MAP_BEGIN(PedalboardItem) JSON_MAP_REFERENCE_CONDITIONAL(PedalboardItem,topChain,IsPedalboardSplitItem) JSON_MAP_REFERENCE_CONDITIONAL(PedalboardItem,bottomChain,&IsPedalboardSplitItem) JSON_MAP_REFERENCE(PedalboardItem,midiBindings) + JSON_MAP_REFERENCE(PedalboardItem,midiChannelBinding) JSON_MAP_REFERENCE(PedalboardItem,stateUpdateCount) JSON_MAP_REFERENCE(PedalboardItem,lv2State) JSON_MAP_REFERENCE(PedalboardItem,lilvPresetUri) JSON_MAP_REFERENCE(PedalboardItem,pathProperties) JSON_MAP_REFERENCE(PedalboardItem,title) + JSON_MAP_REFERENCE(PedalboardItem,useModUi) JSON_MAP_END() @@ -524,6 +538,7 @@ JSON_MAP_BEGIN(Pedalboard) JSON_MAP_REFERENCE(Pedalboard,nextInstanceId) JSON_MAP_REFERENCE(Pedalboard,snapshots) JSON_MAP_REFERENCE(Pedalboard,selectedSnapshot) + JSON_MAP_REFERENCE(Pedalboard,selectedPlugin) JSON_MAP_END() JSON_MAP_BEGIN(SnapshotValue) diff --git a/src/Pedalboard.hpp b/src/Pedalboard.hpp index 1dcf00b..f5d0ab8 100644 --- a/src/Pedalboard.hpp +++ b/src/Pedalboard.hpp @@ -81,7 +81,7 @@ public: }; -class PedalboardItem: public JsonMemberWritable { +class PedalboardItem { public: using PropertyMap = std::map; int64_t instanceId_ = 0; @@ -99,6 +99,7 @@ public: std::string lilvPresetUri_; std::map pathProperties_; std::string title_; + bool useModUi_ = false; // non persistent state. PropertyMap patchProperties; @@ -128,6 +129,7 @@ public: GETTER_SETTER(stateUpdateCount) GETTER_SETTER_REF(lv2State) GETTER_SETTER_REF(title) + GETTER_SETTER(useModUi) Lv2PluginState&lv2State() { return lv2State_; } // non-const version. GETTER_SETTER_REF(lilvPresetUri) @@ -146,17 +148,17 @@ public: void AddResetsForMissingProperties(Snapshot&snapshot, size_t*index) const; - virtual void write_members(json_writer&writer) const { - writer.write_member("instanceId",instanceId_); - writer.write_member("uri",uri_); - writer.write_member("pluginName",pluginName_); - writer.write_member("isEnabled",isEnabled_); - if (isSplit()) - { - writer.write_member("topChain",topChain_); - writer.write_member("bottomChain",bottomChain_); - } - } + // virtual void write_members(json_writer&writer) const { + // writer.write_member("instanceId",instanceId_); + // writer.write_member("uri",uri_); + // writer.write_member("pluginName",pluginName_); + // writer.write_member("isEnabled",isEnabled_); + // if (isSplit()) + // { + // writer.write_member("topChain",topChain_); + // writer.write_member("bottomChain",bottomChain_); + // } + // } DECLARE_JSON_MAP(PedalboardItem); }; @@ -195,6 +197,8 @@ class Pedalboard { std::vector> snapshots_; int64_t selectedSnapshot_ = -1; + int64_t selectedPlugin_ = -1; + public: // deep copy, breaking shared pointers. Pedalboard DeepCopy(); @@ -203,6 +207,7 @@ public: bool SetControlValue(int64_t pedalItemId, const std::string &symbol, float value); bool SetItemTitle(int64_t pedalItemId, const std::string &title); bool SetItemEnabled(int64_t pedalItemId, bool enabled); + bool SetItemUseModUi(int64_t pedalItemId, bool enabled); void SetCurrentSnapshotModified(bool modified); bool IsStructureIdentical(const Pedalboard &other) const; // caan we just send a snapshot-style uddate instead of reloading plugins? All settings are ignored. @@ -221,6 +226,7 @@ public: GETTER_SETTER(output_volume_db) GETTER_SETTER_VEC(snapshots) GETTER_SETTER(selectedSnapshot) + GETTER_SETTER(selectedPlugin) DECLARE_JSON_MAP(Pedalboard); diff --git a/src/PiPedalModel.cpp b/src/PiPedalModel.cpp index 91fe6b8..a3b4e95 100644 --- a/src/PiPedalModel.cpp +++ b/src/PiPedalModel.cpp @@ -45,6 +45,7 @@ #include "AvahiService.hpp" #include "DummyAudioDriver.hpp" #include "AudioFiles.hpp" +#include "CrashGuard.hpp" #ifndef NO_MLOCK #include @@ -242,7 +243,7 @@ void PiPedalModel::LoadLv2PluginInfo() } pluginChangeMonitor = std::make_unique(*this); - pluginHost.Load(configuration.GetLv2Path().c_str()); + pluginHost.LoadLilv(configuration.GetLv2Path().c_str()); // Copy all presets out of Lilv data to json files // so that we can close lilv while we're actually @@ -271,20 +272,33 @@ void PiPedalModel::Load() this->pedalboard = storage.GetCurrentPreset(); // the current *saved* preset. + + // the current edited preset, saved only across orderly shutdowns. - CurrentPreset currentPreset; - try - { - if (storage.RestoreCurrentPreset(¤tPreset)) + + CrashGuard::SetCrashGuardFileName(storage.GetDataRoot() / "crash_guard.data"); + + if (CrashGuard::HasCrashed()) { + // ignore the current preset, and load a blank pedalboard in order to avoid a potential plugin crash. + this->pedalboard = Pedalboard::MakeDefault(); + } else { + CurrentPreset currentPreset; + try { - this->pedalboard = currentPreset.preset_; - this->hasPresetChanged = currentPreset.modified_; + if (storage.RestoreCurrentPreset(¤tPreset)) + { + this->pedalboard = currentPreset.preset_; + this->hasPresetChanged = currentPreset.modified_; + } + } + catch (const std::exception &e) + { + Lv2Log::warning(SS("Failed to load current preset. " << e.what())); } } - catch (const std::exception &e) - { - Lv2Log::warning(SS("Failed to load current preset. " << e.what())); - } + + + UpdateDefaults(&this->pedalboard); std::unique_ptr p{AudioHost::CreateInstance(pluginHost.asIHost())}; @@ -617,6 +631,24 @@ void PiPedalModel::UpdateCurrentPedalboard(int64_t clientId, Pedalboard &pedalbo } } +void PiPedalModel::SetPedalboardItemUseModUi(int64_t clientId, int64_t instanceId, bool enabled) +{ + std::lock_guard guard{mutex}; + { + this->pedalboard.SetItemUseModUi(instanceId, enabled); + + // Notify clients. + std::vector t{subscribers.begin(), subscribers.end()}; + for (auto &subscriber : t) + { + subscriber->OnItemUseModUiChanged(clientId, instanceId, enabled); + } + this->SetPresetChanged(clientId, true); + + } +} + + void PiPedalModel::SetPedalboardItemEnable(int64_t clientId, int64_t pedalItemId, bool enabled) { std::lock_guard guard{mutex}; @@ -2221,7 +2253,10 @@ void PiPedalModel::OnPatchSetReply(uint64_t instanceId, LV2_URID patchSetPropert } } } - audioHost->SetListenForAtomOutput(atomOutputListeners.size() != 0); + if (audioHost) + { + audioHost->SetListenForAtomOutput(atomOutputListeners.size() != 0); + } } void PiPedalModel::OnNotifyPathPatchPropertyReceived( @@ -2577,8 +2612,19 @@ std::shared_ptr PiPedalModel::GetLv2Pedalboard() } return lv2Pedalboard; } + +void PiPedalModel::SetSelectedPedalboardPlugin(uint64_t clientId, uint64_t pedalboardId) +{ + // Thinking on this: + // 1) do NOT mark the pedalboard as changed. This shouldn't set a change flag. + // 2) do NOT broadcast the change. Whoever set it last controls what happens when the plugin is reloaded. Meh. + // 3) Clients must be able to save a non-changed pedalboard. + pedalboard.selectedPlugin(pedalboardId); +} + bool PiPedalModel::LoadCurrentPedalboard() { + CrashGuardLock crashGuardLock; if (previousPedalboardLoaded && pedalboard.IsStructureIdentical(previousPedalboard)) { // then we can send a snapshot update instead! @@ -3057,3 +3103,5 @@ bool PiPedalModel::HasTone3000Auth() const return storage.GetTone3000Auth() != ""; } + + diff --git a/src/PiPedalModel.hpp b/src/PiPedalModel.hpp index d665545..9145be4 100644 --- a/src/PiPedalModel.hpp +++ b/src/PiPedalModel.hpp @@ -59,6 +59,7 @@ namespace pipedal virtual int64_t GetClientId() = 0; virtual void OnItemEnabledChanged(int64_t clientId, int64_t pedalItemId, bool enabled) = 0; + virtual void OnItemUseModUiChanged(int64_t clientId, int64_t pedalItemId, bool enabled) = 0; virtual void OnControlChanged(int64_t clientId, int64_t pedalItemId, const std::string &symbol, float value) = 0; virtual void OnInputVolumeChanged(float value) = 0; virtual void OnOutputVolumeChanged(float value) = 0; @@ -331,7 +332,9 @@ namespace pipedal void LoadLv2PluginInfo(); void Load(); - const PluginHost &GetLv2Host() const { return pluginHost; } + const PluginHost &GetPluginHost() const { return pluginHost; } + PluginHost &GetPluginHost() { return pluginHost; } + Pedalboard GetCurrentPedalboardCopy() { std::lock_guard guard(mutex); @@ -346,6 +349,7 @@ namespace pipedal void RemoveNotificationSubsription(std::shared_ptr pSubscriber); void SetPedalboardItemEnable(int64_t clientId, int64_t instanceId, bool enabled); + void SetPedalboardItemUseModUi(int64_t clientId, int64_t instanceId, bool enabled); void SetControl(int64_t clientId, int64_t pedalItemId, const std::string &symbol, float value); void PreviewControl(int64_t clientId, int64_t pedalItemId, const std::string &symbol, float value); @@ -485,6 +489,8 @@ namespace pipedal void SetTone3000Auth(const std::string &apiKey); bool HasTone3000Auth() const; + void SetSelectedPedalboardPlugin(uint64_t clientId, uint64_t pedalboardId); + }; } // namespace pipedal. \ No newline at end of file diff --git a/src/PiPedalSocket.cpp b/src/PiPedalSocket.cpp index 4740747..83d1bfe 100644 --- a/src/PiPedalSocket.cpp +++ b/src/PiPedalSocket.cpp @@ -428,6 +428,22 @@ JSON_MAP_REFERENCE(PedalboardItemEnabledBody, instanceId) JSON_MAP_REFERENCE(PedalboardItemEnabledBody, enabled) JSON_MAP_END() +class PedalboardItemUseModGuiBody +{ +public: + int64_t clientId_ = -1; + int64_t instanceId_ = -1; + bool useModUi_ = true; + + DECLARE_JSON_MAP(PedalboardItemUseModGuiBody); +}; +JSON_MAP_BEGIN(PedalboardItemUseModGuiBody) +JSON_MAP_REFERENCE(PedalboardItemUseModGuiBody, clientId) +JSON_MAP_REFERENCE(PedalboardItemUseModGuiBody, instanceId) +JSON_MAP_REFERENCE(PedalboardItemUseModGuiBody, useModUi) +JSON_MAP_END() + + class UpdateCurrentPedalboardBody { public: @@ -456,6 +472,21 @@ JSON_MAP_REFERENCE(SetSnapshotsBody, snapshots) JSON_MAP_REFERENCE(SetSnapshotsBody, selectedSnapshot) JSON_MAP_END() +class SetSelectedPedalboardPluginBody +{ +public: + uint64_t clientId_; + uint64_t pluginInstanceId_; + + DECLARE_JSON_MAP(SetSelectedPedalboardPluginBody); +}; + +JSON_MAP_BEGIN(SetSelectedPedalboardPluginBody) +JSON_MAP_REFERENCE(SetSelectedPedalboardPluginBody, clientId) +JSON_MAP_REFERENCE(SetSelectedPedalboardPluginBody, pluginInstanceId) +JSON_MAP_END() + + class SnapshotModifiedBody { public: @@ -1284,6 +1315,12 @@ public: int64_t result = this->model.SaveCurrentPresetAs(this->clientId, body.name_, body.saveAfterInstanceId_); Reply(replyTo, "saveCurrentPresetsAs", result); } + else if (message == "setSelectedPedalboardPlugin") + { + SetSelectedPedalboardPluginBody body; + pReader->read(&body); + this->model.SetSelectedPedalboardPlugin(body.clientId_,body.pluginInstanceId_); + } else if (message == "savePluginPresetAs") { SavePluginPresetAsBody body; @@ -1303,6 +1340,11 @@ public: pReader->read(&body); model.SetPedalboardItemEnable(body.clientId_, body.instanceId_, body.enabled_); } + else if (message == "setPedalboardItemUseModUi") { + PedalboardItemUseModGuiBody body; + pReader->read(&body); + model.SetPedalboardItemUseModUi(body.clientId_, body.instanceId_, body.useModUi_); + } else if (message == "updateCurrentPedalboard") { { @@ -1331,12 +1373,12 @@ public: } else if (message == "plugins") { - auto ui_plugins = model.GetLv2Host().GetUiPlugins(); + auto ui_plugins = model.GetPluginHost().GetUiPlugins(); Reply(replyTo, "plugins", ui_plugins); } else if (message == "pluginClasses") { - auto classes = model.GetLv2Host().GetLv2PluginClass(); + auto classes = model.GetPluginHost().GetLv2PluginClass(); Reply(replyTo, "pluginClasses", classes); } else if (message == "hello") @@ -2221,6 +2263,15 @@ private: body.enabled_ = enabled; Send("onItemEnabledChanged", body); } + virtual void OnItemUseModUiChanged(int64_t clientId, int64_t pedalItemId, bool enabled) + { + PedalboardItemEnabledBody body; + body.clientId_ = clientId; + body.instanceId_ = pedalItemId; + body.enabled_ = enabled; + Send("onUseItemModUiChanged", body); + } + }; std::atomic PiPedalSocketHandler::nextClientId = 0; diff --git a/src/PiPedalUI.cpp b/src/PiPedalUI.cpp index abe46f6..fd3215f 100644 --- a/src/PiPedalUI.cpp +++ b/src/PiPedalUI.cpp @@ -140,6 +140,7 @@ UiFileType::UiFileType(PluginHost *pHost, const LilvNode *node) + UiFileProperty::UiFileProperty(PluginHost *pHost, const LilvNode *node, const std::filesystem::path &resourcePath) { auto pWorld = pHost->getWorld(); @@ -455,6 +456,13 @@ bool UiFileType::IsValidExtension(const std::string&extension) const { return false; } +UiFileType::UiFileType(const std::string&label, const std::string &mimeType,const std::string &fileType) +: label_(label) +, mimeType_(mimeType) +, fileExtension_(fileType) +{ +} + UiFileType::UiFileType(const std::string&label, const std::string &fileType) : label_(label) , fileExtension_(fileType) diff --git a/src/PiPedalUI.hpp b/src/PiPedalUI.hpp index 9a2ef3f..53f274d 100644 --- a/src/PiPedalUI.hpp +++ b/src/PiPedalUI.hpp @@ -75,6 +75,7 @@ namespace pipedal { class PluginHost; + class ModFileTypes; class UiFileType { @@ -87,12 +88,16 @@ namespace pipedal UiFileType() {} UiFileType(PluginHost *pHost, const LilvNode *node); UiFileType(const std::string &label, const std::string &fileType); + UiFileType(const std::string &label, const std::string&mimeType, const std::string &fileType); static std::vector GetArray(PluginHost *pHost, const LilvNode *node, const LilvNode *uri); const std::string &label() const { return label_; } + void label(const std::string &value) { label_ = value; } const std::string &fileExtension() const { return fileExtension_; } + void fileExtension(const std::string &value) { fileExtension_ = value; } const std::string &mimeType() const { return mimeType_; } + void mimeType(const std::string &value) { mimeType_ = value; } bool IsValidExtension(const std::string &extension) const; public: @@ -160,6 +165,8 @@ namespace pipedal const std::vector &fileTypes() const { return fileTypes_; } std::vector &fileTypes() { return fileTypes_; } + void fileTypes(const std::vector &value) { fileTypes_ = value; } + void fileTypes(std::vector &&value) { fileTypes_ = std::move(value); } const std::string &patchProperty() const { return patchProperty_; } diff --git a/src/PipewireInputStreamTest.cpp b/src/PipewireInputStreamTest.cpp index 09cb03c..fb59bf9 100644 --- a/src/PipewireInputStreamTest.cpp +++ b/src/PipewireInputStreamTest.cpp @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Robin Davies +// Copyright (c) 2025 Robin Davies // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in diff --git a/src/PluginHost.cpp b/src/PluginHost.cpp index e6355cf..5a796cb 100644 --- a/src/PluginHost.cpp +++ b/src/PluginHost.cpp @@ -136,8 +136,11 @@ void PluginHost::LilvUris::Initialize(LilvWorld *pWorld) lv2core__symbol = lilv_new_uri(pWorld, LV2_CORE__symbol); lv2core__name = lilv_new_uri(pWorld, LV2_CORE__name); + lv2core__shortName = lilv_new_uri(pWorld, LV2_CORE_PREFIX "shortName"); // ?? from mod sources lv2core__index = lilv_new_uri(pWorld, LV2_CORE__index); lv2core__Parameter = lilv_new_uri(pWorld, LV2_CORE_PREFIX "Parameter"); + lv2core__minorVersion = lilv_new_uri(pWorld, LV2_CORE__minorVersion); + lv2core__microVersion = lilv_new_uri(pWorld, LV2_CORE__microVersion); pipedalUI__ui = lilv_new_uri(pWorld, PIPEDAL_UI__ui); pipedalUI__fileProperties = lilv_new_uri(pWorld, PIPEDAL_UI__fileProperties); @@ -150,7 +153,7 @@ void PluginHost::LilvUris::Initialize(LilvWorld *pWorld) pipedalUI__mimeType = lilv_new_uri(pWorld, PIPEDAL_UI__mimeType); pipedalUI__outputPorts = lilv_new_uri(pWorld, PIPEDAL_UI__outputPorts); pipedalUI__text = lilv_new_uri(pWorld, PIPEDAL_UI__text); - pipedalUI__ledColor = lilv_new_uri(pWorld,PIPEDAL_UI__ledColor); + pipedalUI__ledColor = lilv_new_uri(pWorld, PIPEDAL_UI__ledColor); pipedalUI__frequencyPlot = lilv_new_uri(pWorld, PIPEDAL_UI__frequencyPlot); pipedalUI__xLeft = lilv_new_uri(pWorld, PIPEDAL_UI__xLeft); @@ -159,7 +162,7 @@ void PluginHost::LilvUris::Initialize(LilvWorld *pWorld) pipedalUI__yBottom = lilv_new_uri(pWorld, PIPEDAL_UI__yBottom); pipedalUI__xLog = lilv_new_uri(pWorld, PIPEDAL_UI__xLog); pipedalUI__width = lilv_new_uri(pWorld, PIPEDAL_UI__width); - pipedalUI__graphicEq = lilv_new_uri(pWorld,PIPEDAL_UI__graphicEq); + pipedalUI__graphicEq = lilv_new_uri(pWorld, PIPEDAL_UI__graphicEq); ui__portNotification = lilv_new_uri(pWorld, LV2_UI__portNotification); ui__plugin = lilv_new_uri(pWorld, LV2_UI__plugin); @@ -171,6 +174,7 @@ void PluginHost::LilvUris::Initialize(LilvWorld *pWorld) lv2__port = lilv_new_uri(pWorld, LV2_CORE__port); #define MOD_PREFIX "http://moddevices.com/ns/mod#" + mod__label = lilv_new_uri(pWorld, MOD_PREFIX "label"); mod__brand = lilv_new_uri(pWorld, MOD_PREFIX "brand"); mod__preferMomentaryOffByDefault = lilv_new_uri(pWorld, MOD_PREFIX "preferMomentaryOffByDefault"); @@ -194,16 +198,39 @@ void PluginHost::LilvUris::Initialize(LilvWorld *pWorld) patch__writable = lilv_new_uri(pWorld, LV2_PATCH__writable); patch__readable = lilv_new_uri(pWorld, LV2_PATCH__readable); - pipedal_patch__readable = lilv_new_uri(pWorld, PIPEDAL_PATCH__readable); + pipedal_patch__readable = lilv_new_uri(pWorld, PIPEDAL_PATCH__readable); dc__format = lilv_new_uri(pWorld, "http://purl.org/dc/terms/format"); mod__fileTypes = lilv_new_uri(pWorld, "http://moddevices.com/ns/mod#fileTypes"); + mod__supportedExtensions = lilv_new_uri(pWorld, "http://moddevices.com/ns/mod#supportedExtensions"); } void PluginHost::LilvUris::Free() { } +static int32_t nodesAsInt(const LilvNodes *nodes) +{ + if (!nodes) + return 0; + + LILV_FOREACH(nodes, iNode, nodes) + { + const LilvNode *node = lilv_nodes_get(nodes, iNode); + if (!node) + return 0; + if (lilv_node_is_int(node)) + { + return lilv_node_as_int(node); + } + if (lilv_node_is_float(node)) + { + return static_cast(lilv_node_as_float(node)); + } + break; + } + return 0; +} static std::string nodeAsString(const LilvNode *node) { @@ -270,7 +297,6 @@ PluginHost::PluginHost() fileMetadataFeature.Prepare(mapFeature); lv2Features.push_back(fileMetadataFeature.GetFeature()); - lv2Features.push_back(nullptr); this->urids = new Urids(mapFeature); @@ -294,8 +320,11 @@ PluginHost::~PluginHost() { delete lilvUris; lilvUris = nullptr; + delete urids; urids = nullptr; + delete mod_gui_uris; + mod_gui_uris = nullptr; free_world(); } @@ -397,7 +426,7 @@ void PluginHost::LoadPluginClassesFromLilv() } } -void PluginHost::Load(const char *lv2Path) +void PluginHost::LoadLilv(const char *lv2Path) { this->plugins_.clear(); @@ -418,6 +447,9 @@ void PluginHost::Load(const char *lv2Path) lilv_world_load_all(pWorld); } + // Not a safe pointer,because auto-deleting after freeWorld() would be death. + this->mod_gui_uris = new ModGuiUris(pWorld, mapFeature); + // LilvNode*lv2_path = lilv_new_file_uri(pWorld,NULL,lv2Path); // lilv_world_set_option(world,LILV_OPTION_LV2_PATH,lv) @@ -439,7 +471,8 @@ void PluginHost::Load(const char *lv2Path) if (pluginInfo->hasCvPorts()) { Lv2Log::debug("Plugin %s (%s) skipped. (Has CV ports).", pluginInfo->name().c_str(), pluginInfo->uri().c_str()); - } else if (pluginInfo->hasUnsupportedPatchProperties()) + } + else if (pluginInfo->hasUnsupportedPatchProperties()) { Lv2Log::debug("Plugin %s (%s) skipped. (Has unsupported patch parameters).", pluginInfo->name().c_str(), pluginInfo->uri().c_str()); } @@ -497,20 +530,20 @@ void PluginHost::Load(const char *lv2Path) if (plugin->is_valid()) { #if 1 - // no plugins with more than 2 inputs or outputs. - // no zero-input or zero-output plugins (temporarily disables midi plugins) - // no zero output devices (permanent, I think) - if (info.audio_inputs() > 2 || info.audio_outputs() > 2) { + // no plugins with more than 2 inputs or outputs. + // no zero-input or zero-output plugins (temporarily disables midi plugins) + // no zero output devices (permanent, I think) + if (info.audio_inputs() > 2 || info.audio_outputs() > 2) + { Lv2Log::debug( "Plugin %s (%s) skipped. %d inputs, %d outputs.", plugin->name().c_str(), plugin->uri().c_str(), - (int)info.audio_inputs(),(int)info.audio_outputs()); - + (int)info.audio_inputs(), (int)info.audio_outputs()); } - else if (info.audio_inputs() == 0 && info.audio_outputs() == 0 ) + else if (info.audio_inputs() == 0 && info.audio_outputs() == 0) { Lv2Log::debug("Plugin %s (%s) skipped. No audio i/o.", plugin->name().c_str(), plugin->uri().c_str()); - - } else if (info.audio_inputs() == 0) + } + else if (info.audio_inputs() == 0) { // temporarily disable this feature. Lv2Log::debug("Plugin %s (%s) skipped. No inputs.", plugin->name().c_str(), plugin->uri().c_str()); @@ -536,7 +569,8 @@ void PluginHost::Load(const char *lv2Path) #endif else { - if (info.audio_inputs() == 0) { + if (info.audio_inputs() == 0) + { Lv2Log::debug("************* ZERO INPUTS: %s (%s) skipped. No audio outputs.", plugin->name().c_str(), plugin->uri().c_str()); } ui_plugins_.push_back(std::move(info)); @@ -613,13 +647,44 @@ bool Lv2PluginInfo::HasFactoryPresets(PluginHost *lv2Host, const LilvPlugin *plu return result; } +static std::vector ToPiPedalFileTypes( + const std::set &modFileTypes) +{ + std::vector result; + for (const auto &fileType : modFileTypes) + { + UiFileType uiFileType; + std::string type = fileType; + if (type.find("/") != std::string::npos) // mime type? + { + UiFileType t = UiFileType(type,type,""); + result.push_back(t); + } + else + { + // add a leading dot. + if (!type.starts_with(".")) { + type = "." + type; + } + auto t = UiFileType(SS(type << " file"), type); + result.push_back(t); + } + } + std::sort(result.begin(), result.end(), + [](const UiFileType &left, const UiFileType &right) + { + return left.label() < right.label(); + }); + return result; +} + Lv2PluginInfo::FindWritablePathPropertiesResult Lv2PluginInfo::FindWritablePathProperties(PluginHost *lv2Host, const LilvPlugin *pPlugin) { // example: // -// a lv2:Parameter; + // a lv2:Parameter; // rdfs:label "Model"; // rdfs:range atom:Path. // ... @@ -644,7 +709,6 @@ Lv2PluginInfo::FindWritablePathProperties(PluginHost *lv2Host, const LilvPlugin if (lilv_world_ask(pWorld, propertyUri, lv2Host->lilvUris->rdfs__range, lv2Host->lilvUris->atom__Path)) { - AutoLilvNode label = lilv_world_get(pWorld, propertyUri, lv2Host->lilvUris->rdfs__label, nullptr); std::string strLabel = label.AsString(); @@ -662,28 +726,30 @@ Lv2PluginInfo::FindWritablePathProperties(PluginHost *lv2Host, const LilvPlugin std::make_shared( strLabel, propertyUri.AsUri(), lv2DirectoryName); - AutoLilvNode indexNode = lilv_world_get(pWorld, propertyUri, lv2Host->lilvUris->lv2core__index, nullptr); int32_t index = indexNode.AsInt(-1); fileProperty->index(index); - // if there's a pipedalui_fileTypes node, use that instead. - AutoLilvNode mod__fileTypes = lilv_world_get(pWorld, propertyUri, lv2Host->lilvUris->mod__fileTypes, nullptr); // default: everything. - std::string fileTypes = ModFileTypes::DEFAULT_FILE_TYPES; + std::string fileTypes = ModFileTypes::DEFAULT_FILE_TYPES; if (mod__fileTypes) { // "nam,nammodel" fileTypes = mod__fileTypes.AsString(); } + std::string modFileExtensions; + AutoLilvNode mod__extensionsNode = lilv_world_get(pWorld, propertyUri, lv2Host->lilvUris->mod__supportedExtensions, nullptr); + { + // "wav,flac,mp3" + modFileExtensions = mod__extensionsNode.AsString(); + } + std::string fileExtensions = modFileExtensions; ModFileTypes modFileTypes(fileTypes); - - // Legacy case: audio_uploads/ exists. std::filesystem::path bundleDirectoryName = std::filesystem::path(bundle_path()).parent_path().filename(); @@ -693,24 +759,32 @@ Lv2PluginInfo::FindWritablePathProperties(PluginHost *lv2Host, const LilvPlugin fileProperty->directory(bundleDirectoryName); - if (std::filesystem::exists(legacyUploadPath) - && !std::filesystem::exists(legacyUploadPath / ".migrated")) + if (std::filesystem::exists(legacyUploadPath) && !std::filesystem::exists(legacyUploadPath / ".migrated")) { fileProperty->useLegacyModDirectory(true); fileProperty->directory(bundleDirectoryName); modFileTypes.rootDirectories().push_back(bundleDirectoryName.filename()); // push the private directory! - } else { + } + else + { if (modFileTypes.rootDirectories().size() == 1) { std::string modName = modFileTypes.rootDirectories()[0]; auto modDirectory = modFileTypes.GetModDirectory(modName); fileProperty->directory(modDirectory->pipedalPath); + std::set fileExtensions = modDirectory->fileExtensions; + if (!modFileExtensions.empty()) { + auto extensions = split(modFileExtensions, ','); + fileExtensions = std::set(extensions.begin(), extensions.end()); + } + fileProperty->fileTypes(ToPiPedalFileTypes(fileExtensions)); } } - fileProperties.push_back(fileProperty); - } else { + } + else + { unsupportedPatchProperty = true; } } @@ -718,24 +792,28 @@ Lv2PluginInfo::FindWritablePathProperties(PluginHost *lv2Host, const LilvPlugin } FindWritablePathPropertiesResult result; - - if (fileProperties.size() != 0) { - std::sort(fileProperties.begin(),fileProperties.end(),[](const UiFileProperty::ptr& left,const UiFileProperty::ptr&right) { - // properies with indexes first. - int32_t indexL = left->index(); - if (indexL < 0) indexL = std::numeric_limits::max(); - int32_t indexR = right->index(); - if (indexR < 0) indexR = std::numeric_limits::max(); - if (indexL < indexR) return true; - if (indexL > indexR) return false; + std::sort( + fileProperties.begin(), + fileProperties.end(), + [](const UiFileProperty::ptr &left, const UiFileProperty::ptr &right) + { + // properies with indexes first. + int32_t indexL = left->index(); + if (indexL < 0) + indexL = std::numeric_limits::max(); + int32_t indexR = right->index(); + if (indexR < 0) + indexR = std::numeric_limits::max(); + if (indexL < indexR) + return true; + if (indexL > indexR) + return false; - // there is no natural order. TTL indexing means that the order we see them in is random. - // We can't order them sensibly. Let's at least order them consistently. - return left->label() < right->label(); - - }); + // there is no natural order. TTL indexing means that the order we see them in is random. + // We can't order them sensibly. Let's at least order them consistently. + return left->label() < right->label(); }); result.pipedalUi = std::make_shared(std::move(fileProperties)); } result.hasUnsupportedPatchProperties = unsupportedPatchProperty; @@ -750,7 +828,10 @@ Lv2PluginInfo::Lv2PluginInfo(PluginHost *lv2Host, LilvWorld *pWorld, const LilvP std::string bundleUri = bundleUriNode.AsUri(); - std::string bundlePath = lilv_file_uri_parse(bundleUri.c_str(), nullptr); + char *lilvBundlePath = lilv_file_uri_parse(bundleUri.c_str(), nullptr); + std::string bundlePath = lilvBundlePath; + lilv_free(lilvBundlePath); + if (bundlePath.length() == 0) throw std::logic_error("Bundle uri is not a file uri."); @@ -764,6 +845,12 @@ Lv2PluginInfo::Lv2PluginInfo(PluginHost *lv2Host, LilvWorld *pWorld, const LilvP AutoLilvNode name = (lilv_plugin_get_name(pPlugin)); this->name_ = nodeAsString(name); + AutoLilvNodes minorVersion = lilv_plugin_get_value(pPlugin, lv2Host->lilvUris->lv2core__minorVersion); + this->minorVersion_ = nodesAsInt(minorVersion); + + AutoLilvNodes microVersion = lilv_plugin_get_value(pPlugin, lv2Host->lilvUris->lv2core__microVersion); + this->microVersion_ = nodesAsInt(microVersion); + AutoLilvNode brand = lilv_world_get(pWorld, plugUri, lv2Host->lilvUris->mod__brand, nullptr); this->brand_ = nodeAsString(brand); @@ -838,6 +925,74 @@ Lv2PluginInfo::Lv2PluginInfo(PluginHost *lv2Host, LilvWorld *pWorld, const LilvP } std::sort(ports_.begin(), ports_.end(), ports_sort_compare); + // Fetch patch properties. + { + AutoLilvNodes patchWritables = lilv_world_find_nodes(pWorld, plugUri, lv2Host->lilvUris->patch__writable, nullptr); + + bool unsupportedPatchProperty = false; + LILV_FOREACH(nodes, iNode, patchWritables) + { + AutoLilvNode propertyUri = lilv_nodes_get(patchWritables, iNode); + if (propertyUri) + { + // a lv2:Parameter? + if (lilv_world_ask(pWorld, propertyUri, lv2Host->lilvUris->isA, lv2Host->lilvUris->lv2core__Parameter)) + { + Lv2PatchPropertyInfo propertyInfo{lv2Host, propertyUri}; + propertyInfo.writable(true); + patchProperties_.push_back(std::move(propertyInfo)); + } + } + } + } + { + AutoLilvNodes patchReadables = lilv_world_find_nodes(pWorld, plugUri, lv2Host->lilvUris->patch__readable, nullptr); + + bool unsupportedPatchProperty = false; + LILV_FOREACH(nodes, iNode, patchReadables) + { + AutoLilvNode propertyUri = lilv_nodes_get(patchReadables, iNode); + if (propertyUri) + { + std::string uri = propertyUri.AsUri(); + bool found = false; + for (auto &property : patchProperties_) + { + if (property.uri() == uri) + { + property.readable(true); + found = true; + break; + } + } + if (!found) + { + if (lilv_world_ask(pWorld, propertyUri, lv2Host->lilvUris->isA, lv2Host->lilvUris->lv2core__Parameter)) + { + Lv2PatchPropertyInfo propertyInfo{lv2Host, propertyUri}; + propertyInfo.readable(true); + patchProperties_.push_back(std::move(propertyInfo)); + } + } + } + } + // default state. + { + AutoLilvNodes stateNodes = lilv_plugin_get_value(pPlugin, lv2Host->lilvUris->state__state); + if (stateNodes) + { + auto stateNode = lilv_nodes_get_first(stateNodes); + LilvState *defaultState = lilv_state_new_from_world(pWorld, lv2Host->GetMapFeature().GetMap(), stateNode); + if (defaultState) + { + Lv2Log::debug("Plugin %s (%s) has default state.", this->name_.c_str(), this->uri_.c_str()); + this->hasDefaultState_ = true; + + lilv_state_free(defaultState); + } + } + } + } // Fetch pipedal plugin UI @@ -886,7 +1041,11 @@ Lv2PluginInfo::Lv2PluginInfo(PluginHost *lv2Host, LilvWorld *pWorld, const LilvP ++nOutputs; } } - + if (nOutputs == 0 || nInputs == 0) + { + isValid = false; + } + this->modGui_ = ModGui::Create(lv2Host, pPlugin); this->is_valid_ = isValid; } @@ -1012,22 +1171,21 @@ Lv2PortInfo::Lv2PortInfo(PluginHost *host, const LilvPlugin *plugin, const LilvP this->integer_property_ = lilv_port_has_property(plugin, pPort, host->lilvUris->integer_property_uri); this->mod_momentaryOffByDefault_ = lilv_port_has_property(plugin, pPort, host->lilvUris->mod__preferMomentaryOffByDefault); this->mod_momentaryOnByDefault_ = lilv_port_has_property(plugin, pPort, host->lilvUris->mod__preferMomentaryOnByDefault); - this->pipedal_graphicEq_ = lilv_port_has_property(plugin, pPort, host->lilvUris->pipedalUI__graphicEq); - + this->pipedal_graphicEq_ = lilv_port_has_property(plugin, pPort, host->lilvUris->pipedalUI__graphicEq); this->enumeration_property_ = lilv_port_has_property(plugin, pPort, host->lilvUris->enumeration_property_uri); - this->toggled_property_ = lilv_port_has_property(plugin, pPort, host->lilvUris->core__toggled); this->not_on_gui_ = lilv_port_has_property(plugin, pPort, host->lilvUris->portprops__not_on_gui_property_uri); this->connection_optional_ = lilv_port_has_property(plugin, pPort, host->lilvUris->core__connectionOptional); this->trigger_property_ = lilv_port_has_property(plugin, pPort, host->lilvUris->portprops__trigger); - AutoLilvNode port_ledColor = lilv_port_get(plugin,pPort,host->lilvUris->pipedalUI__ledColor); + AutoLilvNode port_ledColor = lilv_port_get(plugin, pPort, host->lilvUris->pipedalUI__ledColor); if (port_ledColor) { auto value = lilv_node_as_string(port_ledColor); - if (value) { + if (value) + { this->pipedal_ledColor_ = value; } } @@ -1164,6 +1322,8 @@ bool PluginHost::is_a(const std::string &class_, const std::string &target_class Lv2PluginUiInfo::Lv2PluginUiInfo(PluginHost *pHost, const Lv2PluginInfo *plugin) : uri_(plugin->uri()), name_(plugin->name()), + minorVersion_(plugin->minorVersion()), + microVersion_(plugin->microVersion()), brand_(plugin->brand()), label_(plugin->label()), author_name_(plugin->author_name()), @@ -1173,7 +1333,9 @@ Lv2PluginUiInfo::Lv2PluginUiInfo(PluginHost *pHost, const Lv2PluginInfo *plugin) audio_outputs_(0), description_(plugin->comment()), has_midi_input_(false), - has_midi_output_(false) + has_midi_output_(false), + modGui_(plugin->modGui()), + patchProperties_(plugin->patchProperties()) { PLUGIN_MAP_CHECK(); auto pluginClass = pHost->GetPluginClass(plugin->plugin_class()); @@ -1253,7 +1415,7 @@ std::shared_ptr PluginHost::GetPluginInfo(const std::string &uri) return ff->second; } -Lv2Pedalboard *PluginHost::UpdateLv2PedalboardStructure(Pedalboard &pedalboard,Lv2Pedalboard *existingPedalboard,Lv2PedalboardErrorList &errorList) +Lv2Pedalboard *PluginHost::UpdateLv2PedalboardStructure(Pedalboard &pedalboard, Lv2Pedalboard *existingPedalboard, Lv2PedalboardErrorList &errorList) { ExistingEffectMap existingEffects; @@ -1261,7 +1423,8 @@ Lv2Pedalboard *PluginHost::UpdateLv2PedalboardStructure(Pedalboard &pedalboard,L { for (auto &effect : existingPedalboard->GetSharedEffectList()) { - if (effect->IsLv2Effect()) { + if (effect->IsLv2Effect()) + { existingEffects[effect->GetInstanceId()] = effect; } } @@ -1269,7 +1432,7 @@ Lv2Pedalboard *PluginHost::UpdateLv2PedalboardStructure(Pedalboard &pedalboard,L Lv2Pedalboard *pPedalboard = new Lv2Pedalboard(); try { - pPedalboard->Prepare(this, pedalboard, errorList,&existingEffects); + pPedalboard->Prepare(this, pedalboard, errorList, &existingEffects); return pPedalboard; } catch (const std::exception &e) @@ -1495,9 +1658,9 @@ Lv2PortGroup::Lv2PortGroup(PluginHost *lv2Host, const std::string &groupUri) this->uri_ = groupUri; AutoLilvNode uri = lilv_new_uri(pWorld, groupUri.c_str()); - LilvNode *symbolNode = lilv_world_get(pWorld, uri, lv2Host->lilvUris->lv2core__symbol, nullptr); + AutoLilvNode symbolNode = lilv_world_get(pWorld, uri, lv2Host->lilvUris->lv2core__symbol, nullptr); symbol_ = nodeAsString(symbolNode); - LilvNode *nameNode = lilv_world_get(pWorld, uri, lv2Host->lilvUris->lv2core__name, nullptr); + AutoLilvNode nameNode = lilv_world_get(pWorld, uri, lv2Host->lilvUris->lv2core__name, nullptr); name_ = nodeAsString(nameNode); } @@ -1589,17 +1752,16 @@ static void createTargetLinks(const std::filesystem::path &sourceDirectory, cons } } -std::string PluginHost::MapResourcePath(const std::string&pluginUri, const std::string&filePath) +std::string PluginHost::MapResourcePath(const std::string &pluginUri, const std::string &filePath) { auto plugin = GetPluginInfo(pluginUri); - if (plugin) { - + if (plugin) + { + return filePath; } - return filePath; - } void PluginHost::CheckForResourceInitialization(const std::string &pluginUri, const std::filesystem::path &pluginUploadDirectory) @@ -1679,6 +1841,47 @@ std::string PluginHost::AbstractPath(const std::string &path) } return path; } +Lv2PatchPropertyInfo::Lv2PatchPropertyInfo(PluginHost *pluginHost, const LilvNode *propertyUri) +{ + LilvWorld *pWorld = pluginHost->getWorld(); + this->uri_ = nodeAsString(propertyUri); + + AutoLilvNode range = lilv_world_get(pWorld, propertyUri, pluginHost->lilvUris->rdfs__range, nullptr); + if (range) + { + this->type_ = range.AsUri(); + } + AutoLilvNode comment = lilv_world_get(pWorld, propertyUri, pluginHost->lilvUris->rdfs__Comment, nullptr); + if (comment) + { + this->comment_ = comment.AsString(); + } + AutoLilvNode shortName = lilv_world_get(pWorld, propertyUri, pluginHost->lilvUris->lv2core__shortName, nullptr); + if (shortName) + { + this->shortName_ = shortName.AsString(); + } + else + { + this->shortName_ = this->label_; + } + + AutoLilvNode indexNode = lilv_world_get(pWorld, propertyUri, pluginHost->lilvUris->lv2core__index, nullptr); + this->index_ = indexNode.AsInt(-1); + + AutoLilvNode modFileTypesNode = lilv_world_get(pWorld, propertyUri, pluginHost->lilvUris->mod__fileTypes, nullptr); + if (modFileTypesNode) + { + std::string strFileTypes = modFileTypesNode.AsString(); + this->fileTypes_ = split(strFileTypes, ','); + } + AutoLilvNode modSupportedExtensionsNode = lilv_world_get(pWorld, propertyUri, pluginHost->lilvUris->mod__supportedExtensions, nullptr); + if (modSupportedExtensionsNode) + { + std::string strSupportedExtensions = modSupportedExtensionsNode.AsString(); + this->supportedExtensions_ = split(strSupportedExtensions, ','); + } +} // void PiPedalHostLogError(const std::string &error) // { @@ -1757,6 +1960,8 @@ json_map::storage_type Lv2PortGroup::jmap{{ json_map::storage_type Lv2PluginInfo::jmap{{ json_map::reference("bundle_path", &Lv2PluginInfo::bundle_path_), json_map::reference("uri", &Lv2PluginInfo::uri_), + json_map::reference("minorVersion", &Lv2PluginInfo::minorVersion_), + json_map::reference("microVersion", &Lv2PluginInfo::microVersion_), json_map::reference("name", &Lv2PluginInfo::name_), json_map::reference("brand", &Lv2PluginInfo::brand_), json_map::reference("label", &Lv2PluginInfo::label_), @@ -1774,6 +1979,10 @@ json_map::storage_type Lv2PluginInfo::jmap{{ json_map::reference("port_groups", &Lv2PluginInfo::port_groups_), json_map::reference("is_valid", &Lv2PluginInfo::is_valid_), json_map::reference("has_factory_presets", &Lv2PluginInfo::has_factory_presets_), + json_map::reference("modGui", &Lv2PluginInfo::modGui_), + json_map::reference("patchProperties", &Lv2PluginInfo::patchProperties_), + json_map::reference("hasDefaultState", &Lv2PluginInfo::hasDefaultState_), + }}; json_map::storage_type Lv2PluginClass::jmap{{ @@ -1831,6 +2040,8 @@ json_map::storage_type { json_map::reference("uri", &Lv2PluginUiInfo::uri_), json_map::reference("name", &Lv2PluginUiInfo::name_), + json_map::reference("minorVersion", &Lv2PluginUiInfo::minorVersion_), + json_map::reference("microVersion", &Lv2PluginUiInfo::microVersion_), json_map::reference("brand", &Lv2PluginUiInfo::brand_), json_map::reference("label", &Lv2PluginUiInfo::label_), json_map::enum_reference("plugin_type", &Lv2PluginUiInfo::plugin_type_, get_plugin_type_enum_converter()), @@ -1848,4 +2059,16 @@ json_map::storage_type json_map::reference("fileProperties", &Lv2PluginUiInfo::fileProperties_), json_map::reference("frequencyPlots", &Lv2PluginUiInfo::frequencyPlots_), json_map::reference("uiPortNotifications", &Lv2PluginUiInfo::uiPortNotifications_), + json_map::reference("modGui", &Lv2PluginUiInfo::modGui_), + json_map::reference("patchProperties", &Lv2PluginUiInfo::patchProperties_), }}; +JSON_MAP_BEGIN(Lv2PatchPropertyInfo) +JSON_MAP_REFERENCE(Lv2PatchPropertyInfo, uri) +JSON_MAP_REFERENCE(Lv2PatchPropertyInfo, label) +JSON_MAP_REFERENCE(Lv2PatchPropertyInfo, type) +JSON_MAP_REFERENCE(Lv2PatchPropertyInfo, comment) +JSON_MAP_REFERENCE(Lv2PatchPropertyInfo, shortName) +JSON_MAP_REFERENCE(Lv2PatchPropertyInfo, fileTypes) +JSON_MAP_REFERENCE(Lv2PatchPropertyInfo, supportedExtensions) + +JSON_MAP_END() \ No newline at end of file diff --git a/src/PluginHost.hpp b/src/PluginHost.hpp index cafa183..5b9c202 100644 --- a/src/PluginHost.hpp +++ b/src/PluginHost.hpp @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Robin Davies +// Copyright (c) 2025 Robin E. R. Davies // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in @@ -32,6 +32,7 @@ #include #include "IHost.hpp" #include +#include "ModGui.hpp" //#include "lv2.h" #include "Units.hpp" @@ -42,6 +43,7 @@ #include "AutoLilvNode.hpp" #include "PiPedalUI.hpp" #include "MapPathFeature.hpp" +#include "ModGui.hpp" namespace pipedal { @@ -325,6 +327,38 @@ namespace pipedal static json_map::storage_type jmap; }; + class Lv2PatchPropertyInfo { + + private: + std::string uri_; + bool writable_ = false; + bool readable_ = false; + std::string label_; + uint32_t index_ = -1; + std::string type_; + std::string comment_; + std::string shortName_; + std::vector fileTypes_; + std::vector supportedExtensions_; + public: + Lv2PatchPropertyInfo() {} + Lv2PatchPropertyInfo(PluginHost *pluginHost, const LilvNode *propertyUri); + + LV2_PROPERTY_GETSET(uri); + LV2_PROPERTY_GETSET(writable); + LV2_PROPERTY_GETSET(readable); + LV2_PROPERTY_GETSET(label); + LV2_PROPERTY_GETSET_SCALAR(index); + LV2_PROPERTY_GETSET(type); + LV2_PROPERTY_GETSET(comment); + LV2_PROPERTY_GETSET(shortName); + LV2_PROPERTY_GETSET(fileTypes); + LV2_PROPERTY_GETSET(supportedExtensions); + + DECLARE_JSON_MAP(Lv2PatchPropertyInfo); + + }; + class Lv2PortGroup { private: @@ -364,6 +398,8 @@ namespace pipedal bool HasFactoryPresets(PluginHost *lv2Host, const LilvPlugin *plugin); std::string bundle_path_; std::string uri_; + uint32_t minorVersion_ = 0; + uint32_t microVersion_ = 0; std::string name_; std::string plugin_class_; std::string brand_; @@ -380,8 +416,14 @@ namespace pipedal std::string comment_; std::vector> ports_; std::vector> port_groups_; + std::vector patchProperties_; + + bool hasDefaultState_; + bool is_valid_ = false; PiPedalUI::ptr piPedalUI_; + ModGui::ptr modGui_; + bool hasUnsupportedPatchProperties_ = false; bool IsSupportedFeature(const std::string &feature) const; @@ -390,6 +432,8 @@ namespace pipedal LV2_PROPERTY_GETSET(bundle_path) LV2_PROPERTY_GETSET(uri) LV2_PROPERTY_GETSET(name) + LV2_PROPERTY_GETSET(minorVersion) + LV2_PROPERTY_GETSET(microVersion) LV2_PROPERTY_GETSET(brand) LV2_PROPERTY_GETSET(label) LV2_PROPERTY_GETSET(plugin_class) @@ -406,6 +450,9 @@ namespace pipedal LV2_PROPERTY_GETSET(has_factory_presets) LV2_PROPERTY_GETSET(piPedalUI) LV2_PROPERTY_GETSET(hasUnsupportedPatchProperties) + LV2_PROPERTY_GETSET(modGui) + LV2_PROPERTY_GETSET(patchProperties) + LV2_PROPERTY_GETSET(hasDefaultState) const Lv2PortInfo &getPort(const std::string &symbol) { @@ -618,6 +665,8 @@ namespace pipedal private: std::string uri_; std::string name_; + uint32_t minorVersion_ = 0; + uint32_t microVersion_ = 0;; std::string brand_; std::string label_; std::string author_name_; @@ -636,10 +685,15 @@ namespace pipedal std::vector fileProperties_; std::vector frequencyPlots_; std::vector uiPortNotifications_; + ModGui::ptr modGui_; + std::vector patchProperties_; + public: LV2_PROPERTY_GETSET(uri) LV2_PROPERTY_GETSET(name) + LV2_PROPERTY_GETSET(minorVersion) + LV2_PROPERTY_GETSET(microVersion) LV2_PROPERTY_GETSET(brand) LV2_PROPERTY_GETSET(label) LV2_PROPERTY_GETSET(author_name) @@ -657,6 +711,8 @@ namespace pipedal LV2_PROPERTY_GETSET(fileProperties) LV2_PROPERTY_GETSET(frequencyPlots) LV2_PROPERTY_GETSET(uiPortNotifications) + LV2_PROPERTY_GETSET(modGui) + LV2_PROPERTY_GETSET(patchProperties) static json_map::storage_type jmap; }; @@ -679,6 +735,8 @@ namespace pipedal #endif friend class pipedal::AutoLilvNode; friend class pipedal::PiPedalUI; + friend class PluginHostTest; + static const char *RDFS__comment; static const char *RDFS__range; @@ -707,6 +765,7 @@ namespace pipedal AutoLilvNode invada_units__unit; // typo in invada plugins. AutoLilvNode invada_portprops__logarithmic; // typo in invada plugins. + AutoLilvNode atom__bufferType; AutoLilvNode atom__Path; AutoLilvNode presets__preset; @@ -714,8 +773,11 @@ namespace pipedal AutoLilvNode rdfs__label; AutoLilvNode lv2core__symbol; AutoLilvNode lv2core__name; + AutoLilvNode lv2core__shortName; AutoLilvNode lv2core__index; AutoLilvNode lv2core__Parameter; + AutoLilvNode lv2core__minorVersion; + AutoLilvNode lv2core__microVersion; AutoLilvNode pipedalUI__ui; AutoLilvNode pipedalUI__fileProperties; AutoLilvNode pipedalUI__directory; @@ -766,6 +828,7 @@ namespace pipedal AutoLilvNode patch__readable; AutoLilvNode pipedal_patch__readable; + AutoLilvNode mod__brand; AutoLilvNode mod__label; AutoLilvNode mod__preferMomentaryOffByDefault; @@ -773,6 +836,7 @@ namespace pipedal AutoLilvNode dc__format; AutoLilvNode mod__fileTypes; + AutoLilvNode mod__supportedExtensions; AutoLilvNode pipedalui__fileTypes; @@ -817,6 +881,7 @@ namespace pipedal friend class Lv2PluginInfo; friend class Lv2PortInfo; friend class Lv2PortGroup; + friend class ModGui; std::shared_ptr GetPluginClass(const LilvPluginClass *pClass); std::shared_ptr MakePluginClass(const LilvPluginClass *pClass); @@ -924,7 +989,9 @@ namespace pipedal class Urids; - Urids *urids; + Urids *urids = nullptr; + ModGuiUris* mod_gui_uris = nullptr; + void OnConfigurationChanged(const JackConfiguration &configuration, const JackChannelSelection &settings); @@ -933,7 +1000,7 @@ namespace pipedal std::shared_ptr GetLv2PluginClass() const; - std::vector> GetPlugins() const { return plugins_; } + const std::vector>& GetPlugins() const { return plugins_; } const std::vector &GetUiPlugins() const { return ui_plugins_; } virtual std::shared_ptr GetPluginInfo(const std::string &uri) const; @@ -941,7 +1008,7 @@ namespace pipedal static constexpr const char *DEFAULT_LV2_PATH = "/usr/lib/lv2"; void LoadPluginClassesFromJson(std::filesystem::path jsonFile); - void Load(const char *lv2Path = PluginHost::DEFAULT_LV2_PATH); + void LoadLilv(const char *lv2Path = PluginHost::DEFAULT_LV2_PATH); virtual LV2_URID GetLv2Urid(const char *uri) { diff --git a/src/StateInterface.cpp b/src/StateInterface.cpp index 83fe213..a9abc54 100644 --- a/src/StateInterface.cpp +++ b/src/StateInterface.cpp @@ -23,6 +23,7 @@ #include "json.hpp" #include #include +#include "Lv2Log.hpp" using namespace pipedal; @@ -83,6 +84,7 @@ static void CheckState(LV2_State_Status status) case LV2_State_Status::LV2_STATE_SUCCESS: return; default: + case LV2_State_Status::LV2_STATE_ERR_UNKNOWN: lv2Error = "Unknown error."; break; case LV2_State_Status::LV2_STATE_ERR_BAD_TYPE: @@ -126,7 +128,8 @@ Lv2PluginState StateInterface::Save() } catch (const std::exception &e) { - throw std::logic_error(SS("State save failed. " << e.what())); + Lv2Log::debug(SS("State save failed. " << e.what())); + return Lv2PluginState(); // an invalid state. } return std::move(callState.state); diff --git a/src/Units.hpp b/src/Units.hpp index 3ba14f0..604a1f5 100644 --- a/src/Units.hpp +++ b/src/Units.hpp @@ -68,3 +68,4 @@ json_enum_converter *get_units_enum_converter(); } // namespace. + diff --git a/src/WebServer.cpp b/src/WebServer.cpp index 946088f..4a86c9a 100644 --- a/src/WebServer.cpp +++ b/src/WebServer.cpp @@ -37,6 +37,8 @@ #include "Ipv6Helpers.hpp" #include "util.hpp" #include "ofstream_synced.hpp" +#include "ModTemplateGenerator.hpp" + #include #include "WebServer.hpp" @@ -684,7 +686,7 @@ namespace pipedal int threads = 1; size_t maxUploadSize = 512 * 1024 * 1024; - std::thread *pBgThread = nullptr; + std::unique_ptr pBgThread; std::recursive_mutex io_mutex; boost::asio::io_context *pIoContext = nullptr; @@ -741,6 +743,14 @@ namespace pipedal request.set_body_file(bodyFile->Path(),bodyFile->DeleteFile()); bodyFile->Detach(); } + + + virtual void clearBody() override { + // clear the body, but leave the file size intact (e.g for a HEAD request). + request.set_body(""); + + // STUB: Don't know how to clear the body file!! + } virtual void setBodyFile(std::filesystem::path&path, bool deleteWhenDone) override { // cast away const to do what ther request would do if it had that method. request.set_body_file(path,deleteWhenDone); @@ -1408,7 +1418,7 @@ namespace pipedal throw std::runtime_error("Bad state."); } this->signalOnDone = signalOnDone; - this->pBgThread = new std::thread(ThreadProc, this); + this->pBgThread = std::make_unique(ThreadProc, this); } virtual void DisplayIpAddresses() override; diff --git a/src/WebServer.hpp b/src/WebServer.hpp index e121cc2..1d9bd03 100644 --- a/src/WebServer.hpp +++ b/src/WebServer.hpp @@ -43,6 +43,7 @@ public: virtual void setBody(const std::string&body) = 0; virtual void setBodyFile(std::shared_ptr&temporaryFile) = 0; virtual void setBodyFile(std::filesystem::path&path, bool deleteWhenDone) = 0; + virtual void clearBody() = 0; // but leave the file size intact (e.g for a HEAD request). virtual void keepAlive(bool value) = 0; }; diff --git a/src/WebServerConfig.cpp b/src/WebServerConfig.cpp index ae23dc9..abda288 100644 --- a/src/WebServerConfig.cpp +++ b/src/WebServerConfig.cpp @@ -39,6 +39,7 @@ #include "AudioFiles.hpp" #include "util.hpp" #include "HtmlHelper.hpp" +#include "WebServerMod.hpp" #define OLD_PRESET_EXTENSION ".piPreset" #define PRESET_EXTENSION ".piPreset" @@ -249,7 +250,7 @@ public: void GetPluginPresets(const uri &request_uri, std::string *pName, std::string *pContent) { std::string pluginUri = request_uri.query("id"); - auto plugin = model->GetLv2Host().GetPluginInfo(pluginUri); + auto plugin = model->GetPluginHost().GetPluginInfo(pluginUri); *pName = plugin->name(); PluginPresets pluginPresets = model->GetPluginPresets(pluginUri); @@ -1102,4 +1103,7 @@ void pipedal::ConfigureWebServer( std::shared_ptr downloadIntercept = std::make_shared(&model); server.AddRequestHandler(downloadIntercept); + + std::shared_ptr modWebIntercept = ModWebIntercept::Create(&model); + server.AddRequestHandler(modWebIntercept); } diff --git a/src/WebServerMod.cpp b/src/WebServerMod.cpp new file mode 100644 index 0000000..9400f0a --- /dev/null +++ b/src/WebServerMod.cpp @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#include "WebServerMod.hpp" +#include +#include "json_variant.hpp" +#include "ss.hpp" +#include "ModTemplateGenerator.hpp" +#include "HtmlHelper.hpp" +#include "MimeTypes.hpp" + +using namespace pipedal; +namespace fs = std::filesystem; + +namespace pipedal::impl +{ + + class ModWebInterceptImpl : public ModWebIntercept + { + private: + using super = ModWebIntercept; + using self = ModWebInterceptImpl; + + PiPedalModel *model; + + public: + ModWebInterceptImpl(PiPedalModel *model) + : super("/resources"), + model(model) + { + } + virtual bool wants(const std::string &method, const uri &request_uri) const override; + + virtual void head_response( + const uri &request_uri, + HttpRequest &req, + HttpResponse &res, + std::error_code &ec) override; + + virtual void get_response( + const uri &request_uri, + HttpRequest &req, + HttpResponse &res, + std::error_code &ec) override; + + private: + std::string GenerateTemplate( + const fs::path &templateFile, + std::shared_ptr pluginInfo); + }; +} + +using namespace pipedal::impl; + +ModWebIntercept::ptr ModWebIntercept::Create(PiPedalModel *model) +{ + return std::make_shared(model); +} + +bool ModWebInterceptImpl::wants(const std::string &method, const uri &request_uri) const +{ + if (request_uri.segment_count() < 2 || request_uri.segment(0) != "resources") + { + return false; + } + return true; +} + +void ModWebInterceptImpl::head_response( + const uri &request_uri, + HttpRequest &req, + HttpResponse &res, + std::error_code &ec) +{ + get_response(request_uri, req, res, ec); + res.clearBody(); +} + +static void setCacheControl(HttpResponse &res, const fs::path &path) +{ + if (fs::exists(path)) + { + res.set("Cache-Control", "public, max-age=31536000"); // 1 month + auto lastModified = std::filesystem::last_write_time(path); + res.set(HttpField::LastModified, HtmlHelper::timeToHttpDate(lastModified)); + // Set ETag for cache validation + res.set("ETag", pipedal::HtmlHelper::generateEtag(path)); + } + else + { + res.set("Cache-Control", "no-cache, no-store, must-revalidate"); + } +} +void ModWebInterceptImpl::get_response( + const uri &request_uri, + HttpRequest &req, + HttpResponse &res, + std::error_code &ec) +{ + try + { + std::string ns = request_uri.query("ns"); + std::string strVersion = request_uri.query("v"); + (void)strVersion; // this is just cache-busting, so we don't actually use it. + + std::shared_ptr pluginInfo; + if (!ns.empty()) + { + pluginInfo = model->GetPluginInfo(ns); + if (!pluginInfo) + { + throw std::runtime_error("Plugin not found."); + } + } + if (!pluginInfo->modGui()) + { + throw std::runtime_error("Plugin does not have a ModGui."); + } + if (request_uri.segment_count() < 2) + { + throw std::runtime_error("Invalid request URI."); + } + if (request_uri.segment(0) != "resources") + { + throw std::runtime_error("Invalid request URI: expected 'resources'."); + } + std::string segment = request_uri.segment(1); + if (segment == "_") + { + segment = request_uri.segment(2); + if (segment == "iconTemplate") + { + res.set("Content-Type", "text/html"); + res.setBody(GenerateTemplate( + pluginInfo->modGui()->iconTemplate(), + pluginInfo)); + setCacheControl(res, pluginInfo->modGui()->iconTemplate()); + } + else if (segment == "stylesheet") + { + res.set("Content-Type", "text/css"); + res.setBody(GenerateTemplate( + pluginInfo->modGui()->stylesheet(), + pluginInfo)); + setCacheControl(res, pluginInfo->modGui()->stylesheet()); + + } + + else if (segment == "screenshot") + { + std::filesystem::path path = pluginInfo->modGui()->screenshot(); + if (!fs::exists(path)) + { + ec = std::make_error_code(std::errc::no_such_file_or_directory); + return; + } + if (!fs::is_regular_file(path)) + { + throw std::runtime_error("Screenshot is not a regular file: " + path.string()); + } + + size_t size = fs::file_size(path); + std::string mimeType = MimeTypes::instance().MimeTypeFromExtension(path.extension().string()); + if (mimeType.empty()) + { + throw std::runtime_error("Unknown file type for screenshot: " + path.string()); + } + res.setBodyFile( + path, + false); // delete when done + res.set("Content-Type", mimeType); + res.setContentLength(size); + setCacheControl(res, path); + + } + else if (segment == "thumbnail") + { + std::filesystem::path path = pluginInfo->modGui()->thumbnail(); + if (!fs::exists(path)) + { + throw std::runtime_error("Thumbnail not found: " + path.string()); + } + if (!fs::is_regular_file(path)) + { + throw std::runtime_error("Thumbnail is not a regular file: " + path.string()); + } + + size_t size = fs::file_size(path); + std::string mimeType = MimeTypes::instance().MimeTypeFromExtension(path.extension().string()); + if (mimeType.empty()) + { + throw std::runtime_error("Unknown file type for thumbnail: " + path.string()); + } + res.setBodyFile( + path, + false); // delete when done + res.set("Content-Type", mimeType); + setCacheControl(res, path); + res.setContentLength(size); + } else { + throw std::runtime_error("Unknown resource: _/" + segment); + } + } + else + { + // a request for a plugin resource file. + fs::path resourcefile = pluginInfo->modGui()->resourceDirectory(); + for (size_t i = 1; i < request_uri.segment_count(); ++i) + { + resourcefile /= request_uri.segment(i); + } + if (!fs::exists(resourcefile) && !fs::is_regular_file(resourcefile)) + { + ec = std::make_error_code(std::errc::no_such_file_or_directory); + return; + } + size_t size = fs::file_size(resourcefile); + std::string mimeType = MimeTypes::instance().MimeTypeFromExtension(resourcefile.extension().string()); + if (mimeType.empty()) + { + throw std::runtime_error("Unknown file type for resource: " + resourcefile.string()); + } + res.setBodyFile( + resourcefile, + false); // delete when done + res.set("Content-Type", mimeType); + Lv2Log::debug(SS("ModWebIntercept: Serving resource file: " << resourcefile.string() << " (" << size << " bytes)")); + Lv2Log::debug(SS(" url: " << request_uri.str())); + setCacheControl(res, resourcefile); + res.setContentLength(size); + } + } + catch (const std::exception &e) + { + res.setBody("Error: " + std::string(e.what())); + ec = boost::system::errc::make_error_code(boost::system::errc::invalid_argument); + return; + } +} + +static std::string makeCns(const std::string &encodedUri, int64_t instanceId) +{ + std::stringstream ss; + ss << "_"; + for (char c : encodedUri) + { + if ( + (c >= 'a' && c <= 'z') || (c >= 'A' & c <= 'Z') || (c >= '0' && c <= '9')) + { + ss << c; + } + else + { + ss << '_'; // replace non-alphanumeric characters with '_' + } + } + ss << "_"; + if (instanceId < 0) + { + ss << "_"; + instanceId = -instanceId; + } + ss << instanceId; + return ss.str(); +} + +std::string ModWebInterceptImpl::GenerateTemplate( + const fs::path &templateFile, + std::shared_ptr pluginInfo) +{ + if (!fs::exists(templateFile)) + { + throw std::runtime_error("File not found. " + templateFile.string()); + } + + json_variant context = MakeModGuiTemplateData(pluginInfo); + int64_t version = pluginInfo->minorVersion() * 1000 + + pluginInfo->microVersion(); + + std::string encodedUri = HtmlHelper::encode_url_segment(pluginInfo->uri(), true); + std::string ns = SS("?ns=" << encodedUri << "&v=" << version); + context["_ns"] = ns; + + std::string cns = makeCns(encodedUri, version); + context["_cns"] = cns; + + return GenerateFromTemplateFile( + templateFile, + context); +} diff --git a/src/WebServerMod.hpp b/src/WebServerMod.hpp new file mode 100644 index 0000000..0326be5 --- /dev/null +++ b/src/WebServerMod.hpp @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#pragma once + +#include "WebServer.hpp" + +#include "PiPedalModel.hpp" +#include + + +namespace pipedal { + + + class ModWebIntercept : public RequestHandler + { + + PiPedalModel *model; + protected: + ModWebIntercept(const std::string &target_url) + : RequestHandler(target_url.c_str()) + { + } + public: + using self = ModWebIntercept; + using super = RequestHandler; + using ptr = std::shared_ptr; + + static ptr Create(PiPedalModel *model); + + ModWebIntercept(PiPedalModel *model) + : RequestHandler("/var"), + model(model) + { + } + virtual ~ModWebIntercept() = default; + + + }; + +} \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 12ead14..99c823c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -41,6 +41,7 @@ #include "HtmlHelper.hpp" #include #include +#include "AlsaDriver.hpp" #include #include @@ -82,27 +83,27 @@ static bool isJackServiceRunning() return std::filesystem::exists(path); } -#if ENABLE_BACKTRACE -void segvHandler(int sig) -{ - void *array[10]; +#if 0 && ENABLE_BACKTRACE +// void segvHandler(int sig) +// { +// void *array[10]; - // Get void*'s for all entries on the stack - size_t size; - size = backtrace(array, 10); +// // Get void*'s for all entries on the stack +// size_t size; +// size = backtrace(array, 10); - // Print out all the frames to stderr - const char *message = "Error: SEGV signal received.\n"; - auto _ = write(STDERR_FILENO, message, strlen(message)); +// // Print out all the frames to stderr +// const char *message = "Error: SEGV signal received.\n"; +// auto _ = write(STDERR_FILENO, message, strlen(message)); - backtrace_symbols_fd(array + 2, size - 2, STDERR_FILENO); - _exit(EXIT_FAILURE); -} +// backtrace_symbols_fd(array + 2, size - 2, STDERR_FILENO); +// _exit(EXIT_FAILURE); +// } -static void EnableBacktrace() -{ - signal(SIGSEGV, segvHandler); -} +// static void EnableBacktrace() +// { +// signal(SIGSEGV, segvHandler); +// } #endif static bool TryGetLogLevel(const std::string &strLogLevel, LogLevel *result) @@ -313,6 +314,7 @@ int main(int argc, char *argv[]) sigset_t sigSet; int s; sigemptyset(&sigSet); + sigaddset(&sigSet, SIGINT); sigaddset(&sigSet, SIGTERM); sigaddset(&sigSet, SIGUSR1); @@ -421,6 +423,8 @@ int main(int argc, char *argv[]) } Lv2Log::info("Shutdown complete."); + FreeAlsaGlobals(); + if (systemd) { sd_notify(0, "STOPPING=1"); diff --git a/todo.txt b/todo.txt index 940fc5d..e4a646c 100644 --- a/todo.txt +++ b/todo.txt @@ -1,13 +1,20 @@ -Exctly one underrun per seek in Toob Player +Crash-proofing: do not reload pedalboard if the previous run crashed before normal shutdown. -Test autohotspot detection. +MOD ir uses some scheme to limit the minimum buffer size, which is not implemented in Pipedal. +wa +Vu Meters, move rendering to background images, in order to reduce layout overhead. + +check reload after change of LV2 plugins. + +Exactly one underrun per seek in Toob Player check filetime conversion in gcc 12.2! (missing c++ 20 time conversion functions) AudioFiles.cpp fileTimeToInt64 +json "skip" code doesn't work with complex objects (MidiChannelBinding specifically). - pipewire aux in? @@ -26,3 +33,4 @@ pcm.pipedal_aux_in { } } + diff --git a/valgrind.sh b/valgrind.sh new file mode 100755 index 0000000..58d1723 --- /dev/null +++ b/valgrind.sh @@ -0,0 +1 @@ +valgrind --leak-check=full --log-file=vg.output ./build/src/pipedald /etc/pipedal/config ./vite/dist -port 0.0.0.0:8080 -log-level debug diff --git a/vg.output b/vg.output new file mode 100644 index 0000000..ae1253a --- /dev/null +++ b/vg.output @@ -0,0 +1,146 @@ +==5249== Memcheck, a memory error detector +==5249== Copyright (C) 2002-2022, and GNU GPL'd, by Julian Seward et al. +==5249== Using Valgrind-3.19.0 and LibVEX; rerun with -h for copyright info +==5249== Command: ./build/src/pipedald /etc/pipedal/config ./vite/dist -port 0.0.0.0:8080 -log-level debug +==5249== Parent PID: 5248 +==5249== +==5249== +==5249== HEAP SUMMARY: +==5249== in use at exit: 88,737 bytes in 474 blocks +==5249== total heap usage: 662,523 allocs, 662,049 frees, 52,720,309 bytes allocated +==5249== +==5249== 22 bytes in 1 blocks are definitely lost in loss record 11 of 102 +==5249== at 0x48850C8: malloc (vg_replace_malloc.c:381) +==5249== by 0x61DC32F: ??? +==5249== by 0x61BFFC3: ??? +==5249== by 0x61C01DB: ??? +==5249== by 0x61BF9D7: ??? +==5249== by 0x61C0267: ??? +==5249== by 0x61C0677: ??? +==5249== by 0x61C0C1B: ??? +==5249== by 0x61C0C47: ??? +==5249== by 0x5F143DB: ??? +==5249== by 0x5F14613: ??? +==5249== by 0x6046B87: ??? +==5249== +==5249== 56 bytes in 1 blocks are definitely lost in loss record 45 of 102 +==5249== at 0x48850C8: malloc (vg_replace_malloc.c:381) +==5249== by 0x6233FC7: ??? +==5249== by 0x6232503: ??? +==5249== by 0x6233197: ??? +==5249== by 0x6233343: ??? +==5249== by 0x6233AB7: ??? +==5249== by 0x5F14453: ??? +==5249== by 0x5F14613: ??? +==5249== by 0x6046B87: ??? +==5249== by 0x5EFD7B7: ??? +==5249== by 0x5EFE143: ??? +==5249== by 0x60467A7: ??? +==5249== +==5249== 88 bytes in 1 blocks are definitely lost in loss record 52 of 102 +==5249== at 0x48850C8: malloc (vg_replace_malloc.c:381) +==5249== by 0x625EF8B: ??? +==5249== by 0x61CD1E7: ??? +==5249== by 0x61CD3AF: ??? +==5249== by 0x61CD46B: ??? +==5249== by 0x5F1C0E7: ??? +==5249== by 0x5F0A637: ??? +==5249== by 0x5F143AB: ??? +==5249== by 0x5F14613: ??? +==5249== by 0x6046B87: ??? +==5249== by 0x5EFD7B7: ??? +==5249== by 0x5EFE143: ??? +==5249== +==5249== 3,352 (80 direct, 3,272 indirect) bytes in 1 blocks are definitely lost in loss record 89 of 102 +==5249== at 0x48850C8: malloc (vg_replace_malloc.c:381) +==5249== by 0x6235507: ??? +==5249== by 0x61BFFEB: ??? +==5249== by 0x61C01DB: ??? +==5249== by 0x61BF9D7: ??? +==5249== by 0x61C0267: ??? +==5249== by 0x61C0677: ??? +==5249== by 0x61C0C1B: ??? +==5249== by 0x61C0C47: ??? +==5249== by 0x5F143DB: ??? +==5249== by 0x5F14613: ??? +==5249== by 0x6046B87: ??? +==5249== +==5249== 3,709 (80 direct, 3,629 indirect) bytes in 1 blocks are definitely lost in loss record 91 of 102 +==5249== at 0x48850C8: malloc (vg_replace_malloc.c:381) +==5249== by 0x6235507: ??? +==5249== by 0x626268B: ??? +==5249== by 0x604751B: ??? +==5249== by 0x624C53B: ??? +==5249== by 0x6046AA7: ??? +==5249== by 0x6046C17: ??? +==5249== by 0x5EFD7B7: ??? +==5249== by 0x5EFE143: ??? +==5249== by 0x60467A7: ??? +==5249== by 0x1B727F: CollatorImpl::CollatorImpl(std::shared_ptr, char const*) (Locale.cpp:385) +==5249== by 0x1B7717: LocaleImpl::GetCollator() (Locale.cpp:444) +==5249== +==5249== 3,950 (56 direct, 3,894 indirect) bytes in 1 blocks are definitely lost in loss record 92 of 102 +==5249== at 0x48850C8: malloc (vg_replace_malloc.c:381) +==5249== by 0x625EF8B: ??? +==5249== by 0x624BE7B: ??? +==5249== by 0x60460BF: ??? +==5249== by 0x6046C0B: ??? +==5249== by 0x5EFD7B7: ??? +==5249== by 0x5EFE143: ??? +==5249== by 0x60467A7: ??? +==5249== by 0x1B727F: CollatorImpl::CollatorImpl(std::shared_ptr, char const*) (Locale.cpp:385) +==5249== by 0x1B7717: LocaleImpl::GetCollator() (Locale.cpp:444) +==5249== by 0x156B67: main (main.cpp:303) +==5249== +==5249== 4,264 bytes in 1 blocks are definitely lost in loss record 99 of 102 +==5249== at 0x48850C8: malloc (vg_replace_malloc.c:381) +==5249== by 0x625EFDF: ??? +==5249== by 0x61C063F: ??? +==5249== by 0x61C0C1B: ??? +==5249== by 0x61C0C47: ??? +==5249== by 0x5F143DB: ??? +==5249== by 0x5F14613: ??? +==5249== by 0x6046B87: ??? +==5249== by 0x5EFD7B7: ??? +==5249== by 0x5EFE143: ??? +==5249== by 0x60467A7: ??? +==5249== by 0x1B727F: CollatorImpl::CollatorImpl(std::shared_ptr, char const*) (Locale.cpp:385) +==5249== +==5249== 5,804 (256 direct, 5,548 indirect) bytes in 1 blocks are definitely lost in loss record 100 of 102 +==5249== at 0x48850C8: malloc (vg_replace_malloc.c:381) +==5249== by 0x625EF8B: ??? +==5249== by 0x5F143CF: ??? +==5249== by 0x5F14613: ??? +==5249== by 0x6046B87: ??? +==5249== by 0x5EFD7B7: ??? +==5249== by 0x5EFE143: ??? +==5249== by 0x60467A7: ??? +==5249== by 0x1B727F: CollatorImpl::CollatorImpl(std::shared_ptr, char const*) (Locale.cpp:385) +==5249== by 0x1B7717: LocaleImpl::GetCollator() (Locale.cpp:444) +==5249== by 0x156B67: main (main.cpp:303) +==5249== +==5249== 7,733 bytes in 276 blocks are definitely lost in loss record 101 of 102 +==5249== at 0x488A1C4: realloc (vg_replace_malloc.c:1437) +==5249== by 0x5912A2B: serd_chunk_sink (in /usr/lib/aarch64-linux-gnu/libserd-0.so.0.30.16) +==5249== by 0x5912A87: serd_chunk_sink_finish (in /usr/lib/aarch64-linux-gnu/libserd-0.so.0.30.16) +==5249== by 0x590DA67: serd_file_uri_parse (in /usr/lib/aarch64-linux-gnu/libserd-0.so.0.30.16) +==5249== by 0x470DE3: pipedal::Lv2PluginInfo::Lv2PluginInfo(pipedal::PluginHost*, LilvWorldImpl*, LilvPluginImpl const*) (PluginHost.cpp:831) +==5249== by 0x49D8F7: void std::_Construct(pipedal::Lv2PluginInfo*, pipedal::PluginHost*&&, LilvWorldImpl*&, LilvPluginImpl const*&) (stl_construct.h:119) +==5249== by 0x49A61F: void std::allocator_traits >::construct(std::allocator&, pipedal::Lv2PluginInfo*, pipedal::PluginHost*&&, LilvWorldImpl*&, LilvPluginImpl const*&) (alloc_traits.h:635) +==5249== by 0x496C5F: std::_Sp_counted_ptr_inplace, (__gnu_cxx::_Lock_policy)2>::_Sp_counted_ptr_inplace(std::allocator, pipedal::PluginHost*&&, LilvWorldImpl*&, LilvPluginImpl const*&) (shared_ptr_base.h:604) +==5249== by 0x48F12B: std::__shared_count<(__gnu_cxx::_Lock_policy)2>::__shared_count, pipedal::PluginHost*, LilvWorldImpl*&, LilvPluginImpl const*&>(pipedal::Lv2PluginInfo*&, std::_Sp_alloc_shared_tag >, pipedal::PluginHost*&&, LilvWorldImpl*&, LilvPluginImpl const*&) (shared_ptr_base.h:971) +==5249== by 0x488A3B: std::__shared_ptr::__shared_ptr, pipedal::PluginHost*, LilvWorldImpl*&, LilvPluginImpl const*&>(std::_Sp_alloc_shared_tag >, pipedal::PluginHost*&&, LilvWorldImpl*&, LilvPluginImpl const*&) (shared_ptr_base.h:1712) +==5249== by 0x482803: std::shared_ptr::shared_ptr, pipedal::PluginHost*, LilvWorldImpl*&, LilvPluginImpl const*&>(std::_Sp_alloc_shared_tag >, pipedal::PluginHost*&&, LilvWorldImpl*&, LilvPluginImpl const*&) (shared_ptr.h:464) +==5249== by 0x47E363: std::shared_ptr std::make_shared(pipedal::PluginHost*&&, LilvWorldImpl*&, LilvPluginImpl const*&) (shared_ptr.h:1010) +==5249== +==5249== LEAK SUMMARY: +==5249== definitely lost: 12,635 bytes in 284 blocks +==5249== indirectly lost: 16,343 bytes in 30 blocks +==5249== possibly lost: 0 bytes in 0 blocks +==5249== still reachable: 59,759 bytes in 160 blocks +==5249== suppressed: 0 bytes in 0 blocks +==5249== Reachable blocks (those to which a pointer was found) are not shown. +==5249== To see them, rerun with: --leak-check=full --show-leak-kinds=all +==5249== +==5249== For lists of detected and suppressed errors, rerun with: -s +==5249== ERROR SUMMARY: 9 errors from 9 contexts (suppressed: 0 from 0) diff --git a/vite/CMakeLists.txt b/vite/CMakeLists.txt index f5f87e3..b881fe9 100644 --- a/vite/CMakeLists.txt +++ b/vite/CMakeLists.txt @@ -108,7 +108,6 @@ add_custom_command( public/iso_codes.json public/logo512.png public/sample_lv2_plugins.json - public/vite.svg src/pipedal/SplitUiControls.tsx src/pipedal/JackHostStatus.tsx src/pipedal/VirtualKeyboardHandler.tsx @@ -291,8 +290,6 @@ add_custom_command( src/pipedal/MidiChannelBinding.tsx src/pipedal/ModFileTypes.tsx src/assets/react.svg - src/App.css - src/App.tsx src/vite-env.d.ts src/index.css src/main.tsx diff --git a/vite/index.html b/vite/index.html index 462145c..3a6c884 100644 --- a/vite/index.html +++ b/vite/index.html @@ -1,112 +1,133 @@ - + + - + - + - + - + PiPedal - - + + +
- + + + + diff --git a/vite/public/css/modGui.css b/vite/public/css/modGui.css new file mode 100644 index 0000000..984aa13 --- /dev/null +++ b/vite/public/css/modGui.css @@ -0,0 +1,178 @@ + +@font-face { + font-family: 'cooper hewitt'; + src: url('../fonts/cooper/cooperhewitt-light-webfont.woff2') format('woff2'); + font-weight: 300; + font-style: normal; + font-display: swap +} + +@font-face { + font-family: 'cooper hewitt'; + src: url('../fonts/cooper/cooperhewitt-lightitalic-webfont.woff2') format('woff2'); + font-weight: 300; + font-style: italic; + font-display: swap +} + +@font-face { + font-family: 'cooper hewitt'; + src: url('../fonts/cooper/cooperhewitt-bookitalic-webfont.woff2') format('woff2'); + font-weight: 400; + font-style: italic; + font-display: swap +} + +@font-face { + font-family: 'cooper hewitt'; + src: url('../fonts/cooper/cooperhewitt-book-webfont.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap +} + +@font-face { + font-family: 'cooper hewitt'; + src: url('../fonts/cooper/cooperhewitt-semibold-webfont.woff2') format('woff2'); + font-weight: 600; + font-style: normal; + font-display: swap +} + +.mod-pedal { + touch-action: none; +} + +.mod-pedal .mod-light { +} +.mod-pedal .mod-light.off { background-image:url(../img/red-light-off.png); } +.mod-pedal .mod-light.on { background-image:url(../img/red-light-on.png); } + +.mod-pedal .mod-pedal-input { + position: absolute; + top: 70px; + left: -24px; + width: 24px; + +} + +.mod-pedal .mod-pedal-output { + position: absolute; + top: 70px; + right: -25px; + width: 25px; +} + +.mod-pedal .mod-pedal-input .mod-audio-input, +.mod-pedal .mod-pedal-input .mod-midi-input, +.mod-pedal .mod-pedal-input .mod-cv-input { + position: relative; + margin-bottom: 20px; +} +.mod-audio-input .mod-pedal-input-image { + background-image:url(../img/audio-input.png) ; + width: 24px; + height: 56px; + margin-bottom: 20px; +} +.mod-audio-output .mod-pedal-output-image { + background-image:url(../img/audio-output.png); + width: 24px; + height: 56px; + margin-bottom: 20px; +} + +.mod-pedal-settings .mod-control-group { + margin:0; + padding:0 18px; +} + +.ppmod-dial-control-image { + width: 48px; + height: 48px; + object-fit: fill; + +} +.ppmod-dial-control-frame +{ + position: relative; + width: 48px; + height: 48px; + overflow: hidden; +} +.mod-knob-title { + white-space: nowrap; + overflow: hidden; +} +.mod-slider-title { + white-space: nowrap; + overflow: hidden; +} + + +.mod-pedal .mod-drag-handle { + bottom:0; + left:0; + right:0; + top:0; + cursor:default; /* we dont do drag */ + + position:absolute; + z-index: 0; +} + +.mod-pedal .mod-control-group { + z-index: 20; +} + +.mod-pedal .mod-pedal-input, +.mod-pedal .mod-pedal-output { + z-index:30; +} + + + + +/* mod clearfix hack */ +.mod-pedal .clearfix:before, +.mod-pedal .clearfix:after { + content: "."; + display: block; + height: 0; + overflow: hidden; +} +.mod-pedal.clearfix:after { clear: both; } + +/* enumeration list entries for embedded property browsers. */ + +.ppmod-dotdot-flex { + display: flex; + align-items: center; + flex-flow: row nowrap; +} + +.ppmod-dotdot-text { + white-space: nowrap; + flex: 1 0 auto; + font-size: 0.8em; + padding-right: 8px; +} + +.ppmod-dotdot-path { + white-space: nowrap; + font-size: 0.8em; + flex: 0 1 auto; + overflow: hidden; +} + +.ppmod-dotdot-breadcrumb_link { + display: inline; + white-space: nowrap; + text-decoration: underline +} + +.ppmod-dotdot-breadcrumb_current { + display: inline; + white-space: nowrap; +} + diff --git a/vite/public/css/roboto.css b/vite/public/css/roboto.css index 67bcebf..b7c6b66 100644 --- a/vite/public/css/roboto.css +++ b/vite/public/css/roboto.css @@ -2,28 +2,28 @@ font-family: 'Roboto'; font-style: normal; font-weight: 300; - font-display: swap; + font-display: block; src: url(/fonts/Roboto-Light.woff2) format('woff2'); } @font-face { font-family: 'Roboto'; font-style: normal; font-weight: 400; - font-display: swap; + font-display: block; src: url(/fonts/Roboto-Regular.woff2) format('woff2'); } @font-face { font-family: 'Roboto'; font-style: normal; font-weight: 500; - font-display: swap; + font-display: block; src: url(/fonts/Roboto-Medium.woff2) format('woff2'); } @font-face { font-family: 'Roboto'; font-style: normal; font-weight: 700; - font-display: swap; + font-display: block; src: url(/fonts/Roboto-Bold.woff2) format('woff2'); } \ No newline at end of file diff --git a/vite/public/fonts/comforta/Comfortaa-Bold.ttf b/vite/public/fonts/comforta/Comfortaa-Bold.ttf new file mode 100755 index 0000000..5beebe0 Binary files /dev/null and b/vite/public/fonts/comforta/Comfortaa-Bold.ttf differ diff --git a/vite/public/fonts/comforta/Comfortaa-Light.ttf b/vite/public/fonts/comforta/Comfortaa-Light.ttf new file mode 100755 index 0000000..67fa2f7 Binary files /dev/null and b/vite/public/fonts/comforta/Comfortaa-Light.ttf differ diff --git a/vite/public/fonts/comforta/Comfortaa-Regular.ttf b/vite/public/fonts/comforta/Comfortaa-Regular.ttf new file mode 100755 index 0000000..3f900aa Binary files /dev/null and b/vite/public/fonts/comforta/Comfortaa-Regular.ttf differ diff --git a/vite/public/fonts/cooper/cooperhewitt-book-webfont.woff2 b/vite/public/fonts/cooper/cooperhewitt-book-webfont.woff2 new file mode 100644 index 0000000..e5d9a4c Binary files /dev/null and b/vite/public/fonts/cooper/cooperhewitt-book-webfont.woff2 differ diff --git a/vite/public/fonts/cooper/cooperhewitt-bookitalic-webfont.woff2 b/vite/public/fonts/cooper/cooperhewitt-bookitalic-webfont.woff2 new file mode 100644 index 0000000..ebb0af5 Binary files /dev/null and b/vite/public/fonts/cooper/cooperhewitt-bookitalic-webfont.woff2 differ diff --git a/vite/public/fonts/cooper/cooperhewitt-light-webfont.woff2 b/vite/public/fonts/cooper/cooperhewitt-light-webfont.woff2 new file mode 100644 index 0000000..935d1df Binary files /dev/null and b/vite/public/fonts/cooper/cooperhewitt-light-webfont.woff2 differ diff --git a/vite/public/fonts/cooper/cooperhewitt-lightitalic-webfont.woff2 b/vite/public/fonts/cooper/cooperhewitt-lightitalic-webfont.woff2 new file mode 100644 index 0000000..e791799 Binary files /dev/null and b/vite/public/fonts/cooper/cooperhewitt-lightitalic-webfont.woff2 differ diff --git a/vite/public/fonts/cooper/cooperhewitt-semibold-webfont.woff2 b/vite/public/fonts/cooper/cooperhewitt-semibold-webfont.woff2 new file mode 100644 index 0000000..a925502 Binary files /dev/null and b/vite/public/fonts/cooper/cooperhewitt-semibold-webfont.woff2 differ diff --git a/vite/public/fonts/england-hand/england-webfont.woff b/vite/public/fonts/england-hand/england-webfont.woff new file mode 100755 index 0000000..f79cfd6 Binary files /dev/null and b/vite/public/fonts/england-hand/england-webfont.woff differ diff --git a/vite/public/fonts/england-hand/stylesheet.css b/vite/public/fonts/england-hand/stylesheet.css new file mode 100755 index 0000000..b1b6ab7 --- /dev/null +++ b/vite/public/fonts/england-hand/stylesheet.css @@ -0,0 +1,10 @@ + +@font-face { + font-family: 'EnglandHand'; + src: url('england-webfont.woff'); + font-weight: normal; + font-display: swap; + font-style: normal; + +} + diff --git a/vite/public/fonts/epf/epf_lul-webfont.woff b/vite/public/fonts/epf/epf_lul-webfont.woff new file mode 100755 index 0000000..eebb9a9 Binary files /dev/null and b/vite/public/fonts/epf/epf_lul-webfont.woff differ diff --git a/vite/public/fonts/epf/stylesheet.css b/vite/public/fonts/epf/stylesheet.css new file mode 100755 index 0000000..9f173af --- /dev/null +++ b/vite/public/fonts/epf/stylesheet.css @@ -0,0 +1,7 @@ +@font-face { + font-family: 'epflul'; + src: url('epf_lul-webfont.woff') format('woff'); + font-weight: normal; + font-style: normal; + +} \ No newline at end of file diff --git a/vite/public/fonts/nexa/Nexa_Free_Bold-webfont.woff b/vite/public/fonts/nexa/Nexa_Free_Bold-webfont.woff new file mode 100755 index 0000000..4bce9d8 Binary files /dev/null and b/vite/public/fonts/nexa/Nexa_Free_Bold-webfont.woff differ diff --git a/vite/public/fonts/nexa/stylesheet.css b/vite/public/fonts/nexa/stylesheet.css new file mode 100755 index 0000000..32d7aac --- /dev/null +++ b/vite/public/fonts/nexa/stylesheet.css @@ -0,0 +1,33 @@ +/* + * Web Fonts from fontspring.com + * + * All OpenType features and all extended glyphs have been removed. + * Fully installable fonts can be purchased at http://www.fontspring.com + * + * The fonts included in this stylesheet are subject to the End User License you purchased + * from Fontspring. The fonts are protected under domestic and international trademark and + * copyright law. You are prohibited from modifying, reverse engineering, duplicating, or + * distributing this font software. + * + * (c) 2010-2012 Fontspring + * + * + * + * + * The fonts included are copyrighted by the vendor listed below. + * + * Vendor: Fontfabric + * License URL: http://www.fontspring.com/fflicense/fontfabric + * + * + */ + +@font-face { + font-family: 'Nexa'; + src: url('Nexa_Free_Bold-webfont.woff') format('woff'); + font-display: block; + font-weight: normal; + font-style: normal; + +} + diff --git a/vite/public/fonts/pirulen/pirulen_rg-webfont.woff b/vite/public/fonts/pirulen/pirulen_rg-webfont.woff new file mode 100755 index 0000000..8df692e Binary files /dev/null and b/vite/public/fonts/pirulen/pirulen_rg-webfont.woff differ diff --git a/vite/public/fonts/pirulen/stylesheet.css b/vite/public/fonts/pirulen/stylesheet.css new file mode 100755 index 0000000..b8b9e8c --- /dev/null +++ b/vite/public/fonts/pirulen/stylesheet.css @@ -0,0 +1,9 @@ +/* Generated by Font Squirrel (http://www.fontsquirrel.com) on February 25, 2013 */ + +@font-face { + font-family: 'Pirulen'; + src: url('pirulen_rg-webfont.woff') format('woff'); + font-weight: normal; + font-style: normal; + +} \ No newline at end of file diff --git a/vite/public/fonts/questrial/questrial-regular-webfont.woff b/vite/public/fonts/questrial/questrial-regular-webfont.woff new file mode 100755 index 0000000..0043bff Binary files /dev/null and b/vite/public/fonts/questrial/questrial-regular-webfont.woff differ diff --git a/vite/public/fonts/questrial/stylesheet.css b/vite/public/fonts/questrial/stylesheet.css new file mode 100755 index 0000000..3b1692c --- /dev/null +++ b/vite/public/fonts/questrial/stylesheet.css @@ -0,0 +1,10 @@ +/* Generated by Font Squirrel (http://www.fontsquirrel.com) on February 27, 2013 */ + +@font-face { + font-family: 'Questrial'; + src: url('questrial-regular-webfont.woff') format('woff'); + font-display: block; + font-weight: normal; + font-style: normal; + +} \ No newline at end of file diff --git a/vite/public/img/BlackKnob.png b/vite/public/img/BlackKnob.png new file mode 100755 index 0000000..ad4df1d Binary files /dev/null and b/vite/public/img/BlackKnob.png differ diff --git a/vite/public/img/audio-input.png b/vite/public/img/audio-input.png new file mode 100644 index 0000000..f33e88b Binary files /dev/null and b/vite/public/img/audio-input.png differ diff --git a/vite/public/img/audio-output.png b/vite/public/img/audio-output.png new file mode 100644 index 0000000..6e31da4 Binary files /dev/null and b/vite/public/img/audio-output.png differ diff --git a/vite/public/img/footswitch_strip.png b/vite/public/img/footswitch_strip.png new file mode 100644 index 0000000..9ae4ed5 Binary files /dev/null and b/vite/public/img/footswitch_strip.png differ diff --git a/vite/public/img/red-light-off.png b/vite/public/img/red-light-off.png new file mode 100644 index 0000000..2406921 Binary files /dev/null and b/vite/public/img/red-light-off.png differ diff --git a/vite/public/img/red-light-on.png b/vite/public/img/red-light-on.png new file mode 100644 index 0000000..57ec45a Binary files /dev/null and b/vite/public/img/red-light-on.png differ diff --git a/vite/public/vite.svg b/vite/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/vite/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/vite/src/App.tsx b/vite/src/App.tsx deleted file mode 100644 index eabe4ed..0000000 --- a/vite/src/App.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useState } from 'react' -import ReactLogo from './assets/react.svg?react' -//import ViteLogo from '/vite.svg?react' -import './App.css' - - -function App() { - const [count, setCount] = useState(0) - - return ( - <> - -

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- - ) -} - -export default App diff --git a/vite/src/pipedal/AboutDialog.tsx b/vite/src/pipedal/AboutDialog.tsx index 1780c78..5dfd860 100644 --- a/vite/src/pipedal/AboutDialog.tsx +++ b/vite/src/pipedal/AboutDialog.tsx @@ -152,7 +152,7 @@ const AboutDialog = class extends Component About diff --git a/vite/src/pipedal/AndroidHost.tsx b/vite/src/pipedal/AndroidHost.tsx index 5fe7aa4..18556ac 100644 --- a/vite/src/pipedal/AndroidHost.tsx +++ b/vite/src/pipedal/AndroidHost.tsx @@ -32,6 +32,11 @@ export interface AndroidHostInterface { setThemePreference(theme: number): void; getThemePreference(): number; isDarkTheme?: ()=> boolean; + setServerVersion(serverVersion: string) : void; + setKeepScreenOn(keepScreenOn: boolean): void; + getKeepScreenOn(): boolean; + setScreenOrientation(orientation: number): void; + getScreenOrientation(): number; }; export class FakeAndroidHost implements AndroidHostInterface @@ -40,7 +45,7 @@ export class FakeAndroidHost implements AndroidHostInterface return true; } getHostVersion(): string { - return "Fake Android 1.0"; + return "Fake Host: v1.1.26"; } chooseNewDevice(): void { @@ -64,6 +69,21 @@ export class FakeAndroidHost implements AndroidHostInterface getThemePreference(): number { return this.theme; } + setServerVersion(_serverVersion: string): void { + // No-op for fake host + } + + private keepScreenOn = false; + setKeepScreenOn(keepScreenOn: boolean): void {this.keepScreenOn = keepScreenOn; }; + getKeepScreenOn(): boolean { return this.keepScreenOn; }; + + private screenOrientation = 0; + setScreenOrientation(_orientation: number): void { + this.screenOrientation = _orientation; + } + getScreenOrientation(): number { + return this.screenOrientation; + } } export function isAndroidHosted(): boolean { return ((window as any).AndroidHost as AndroidHostInterface) !== undefined; diff --git a/vite/src/pipedal/App.tsx b/vite/src/pipedal/App.tsx index e2dca51..396d281 100644 --- a/vite/src/pipedal/App.tsx +++ b/vite/src/pipedal/App.tsx @@ -18,13 +18,15 @@ // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import React from 'react'; + import { ThemeProvider, createTheme, StyledEngineProvider } from '@mui/material/styles'; -import CssBaseline from '@mui/material/CssBaseline'; +import CssBaseline from '@mui/material/CssBaseline'; import VirtualKeyboardHandler from './VirtualKeyboardHandler'; import AppThemed from "./AppThemed"; import { isDarkMode } from './DarkMode'; import Tone3000AuthComplete from './Tone3000AuthComplete'; +import FontTest from './FontTest'; declare module '@mui/material/styles' { @@ -225,8 +227,13 @@ type AppThemeProps = { function isTone3000Auth() { let url = new URL(window.location.href); - let param = url.searchParams.get("api_key"); - return (param !== null && param !== "") + let param = url.searchParams.get("api_key"); + return (param !== null && param !== "") +} +function isFontTest() { + let url = new URL(window.location.href); + let param = url.searchParams.get("fontTest"); + return (param !== null) } const App = (class extends React.Component { @@ -248,11 +255,11 @@ const App = (class extends React.Component { - {isTone3000Auth() ? ( - - ) : ( - - )} + { + isTone3000Auth() && () + || isFontTest() && () + || () + } ); diff --git a/vite/src/pipedal/AppThemed.tsx b/vite/src/pipedal/AppThemed.tsx index f4f483f..42d7347 100644 --- a/vite/src/pipedal/AppThemed.tsx +++ b/vite/src/pipedal/AppThemed.tsx @@ -19,6 +19,7 @@ import './AppThemed.css'; + //import {alpha} from '@mui/material/styles'; import AppBar from '@mui/material/AppBar'; @@ -74,6 +75,7 @@ import HelpOutlineIcon from './svg/ic_help_outline.svg?react'; import FxAmplifierIcon from './svg/fx_amplifier.svg?react'; import { PerformanceView } from './PerformanceView'; + import DialogEx from './DialogEx'; import { IDialogStackable, popDialogStack, pushDialogStack } from './DialogStack'; @@ -811,7 +813,7 @@ export {(!this.state.tinyToolBar) && !this.state.performanceView ? ( - + - + diff --git a/vite/src/pipedal/AutoZoom.tsx b/vite/src/pipedal/AutoZoom.tsx new file mode 100644 index 0000000..2914403 --- /dev/null +++ b/vite/src/pipedal/AutoZoom.tsx @@ -0,0 +1,374 @@ +// Copyright (c) 2025 Robin E. R. Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import ResizeResponsiveComponent from './ResizeResponsiveComponent'; +import { Theme } from '@mui/material/styles'; +import WithStyles from './WithStyles'; +import { withStyles } from "tss-react/mui"; +import { createStyles } from './WithStyles'; +import FullscreenIcon from '@mui/icons-material/Fullscreen'; +import IconButtonEx from './IconButtonEx'; +import { isDarkMode } from './DarkMode'; +import Backdrop from '@mui/material/Backdrop'; +import CloseIcon from '@mui/icons-material/Close'; +import Rect from './Rect'; + +import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; + +const styles = (theme: Theme) => createStyles({ + pgraph: { + paddingBottom: 16 + } +}); + + +export interface AutoZoomProps extends WithStyles { + leftPad: number, + rightPad: number, + children?: React.ReactNode, + contentReady: boolean, + showZoomed: boolean, + setShowZoomed: (value: boolean) => void; +} + +export interface AutoZoomState { + screenWidth: number, + screenHeight: number, +} + + + +const AutoZoom = withStyles( + class extends ResizeResponsiveComponent { + private model: PiPedalModel; + constructor(props: AutoZoomProps) { + super(props); + + this.model = PiPedalModelFactory.getInstance(); + void this.model; // suppress unused variable warning + + this.state = { + screenWidth: this.windowSize.width, + screenHeight: this.windowSize.height, + }; + } + mounted: boolean = false; + + + + onWindowSizeChanged(width: number, height: number): void { + this.setState({ screenWidth: width, screenHeight: height }); + } + + + componentDidMount() { + super.componentDidMount(); + this.mounted = true; + if (this.normalRef) { + this.updateZoom(this.normalRef); + } + } + componentWillUnmount() { + this.mounted = false; + this.cancelZoomUpdate(); + this.cancelMaximizedZoomUpdate(); + super.componentWillUnmount(); + } + + + componentDidUpdate(prevProps: AutoZoomProps) { + if (prevProps.contentReady !== this.props.contentReady) { + if (this.normalRef) { + this.updateZoom(this.normalRef); + } + if (this.maximizedRef) { + this.updateMaximizedZoom(); + } + } + } + + resizeObserver: ResizeObserver | null = null; + + + calculateZoomedClientRect( + clientRect: Rect, + contentWidth: number, + contentHeight: number, + buttonPadding: number + ): Rect { + + console.log("Zoom: " + clientRect.toString() + " content: " + contentWidth + "x" + contentHeight + " buttonPadding: " + buttonPadding); + let rightButtonZoom = Math.min((clientRect.width - buttonPadding) / contentWidth, clientRect.height / contentHeight); + let topButtonZoom = Math.min((clientRect.width - buttonPadding) / contentWidth, (clientRect.height - buttonPadding) / contentHeight); + + let effectiveClientRect = clientRect.copy(); + if (rightButtonZoom > topButtonZoom) { + effectiveClientRect.width -= buttonPadding; + } else { + effectiveClientRect.y += buttonPadding; + effectiveClientRect.height -= buttonPadding; + } + + let zoom = Math.min(effectiveClientRect.width / contentWidth, effectiveClientRect.height / contentHeight); + if (zoom > 1) { + zoom = 1; + } + let leftPad = (effectiveClientRect.width - contentWidth * zoom) / 2; + if (leftPad < 0) { + leftPad = 0; + } + let topPad = (effectiveClientRect.height - contentHeight * zoom) / 4; + if (topPad < 0) { + topPad = 0; + } + return new Rect(effectiveClientRect.x + leftPad, effectiveClientRect.y + topPad, contentWidth * zoom, contentHeight * zoom); + } + + updateZoom(ref: HTMLDivElement) { + if (!this.mounted || !ref) { + return; + } + let content = ref.firstElementChild as HTMLElement | null; + + if (content) { + const { leftPad, rightPad } = this.props; + let bounds = new Rect(leftPad, 0, ref.clientWidth - leftPad - rightPad, ref.clientHeight - 8); + let zoomedClientRect = this.calculateZoomedClientRect( + bounds, content.clientWidth, content.clientHeight, 48); + content.style.left = "0px"; + content.style.top = "0px"; + content.style.transformOrigin = "0 0"; // top-left corner + content.style.transform = + `translate(${zoomedClientRect.x}px, ${zoomedClientRect.y}px) scale(${zoomedClientRect.width / content.clientWidth}, ${zoomedClientRect.height / content.clientHeight})`; + + if (this.zoomButton) { + this.zoomButton.style.display = zoomedClientRect.width < content.clientWidth ? "block" : "none"; + let rightSizeSpace = ref.clientWidth - zoomedClientRect.right; + if (rightSizeSpace - rightPad >= 48) { + this.zoomButton.style.top = `${zoomedClientRect.y + 24}px`; + this.zoomButton.style.left = `${zoomedClientRect.right + 4}px`; + } else { + this.zoomButton.style.top = `${zoomedClientRect.y - 48}px`; + this.zoomButton.style.left = `${zoomedClientRect.right - 64 - 12}px`; + } + } + + // save the position of the Mod UI in page coordinates for later zooming. + this.zoomStartRect = zoomedClientRect.copy(); + let refBounds = ref.getBoundingClientRect(); + this.zoomStartRect.x += refBounds.left; + this.zoomStartRect.y += refBounds.top; + } + } + + updateMaximizedZoom() { + let ref = this.maximizedRef; + if (!this.mounted || !ref) { + return; + } + let content = ref.firstElementChild as HTMLElement | null; + + if (content) { + let leftPad = 16; let rightPad = 16; + let topPad = 16, bottomPad = 16; + let bounds: Rect; + bounds = new Rect(leftPad, topPad, ref.clientWidth - leftPad - rightPad, ref.clientHeight - topPad - bottomPad); + + let zoomedClientRect = this.calculateZoomedClientRect( + bounds, content.clientWidth, content.clientHeight, 48); + content.style.left = "0px"; + content.style.top = "0px"; + content.style.transformOrigin = "0 0"; // top-left corner + content.style.transform = `translate(${zoomedClientRect.x}px, ${zoomedClientRect.y}px) scale(${zoomedClientRect.width / content.clientWidth}, ${zoomedClientRect.height / content.clientHeight})`; + //console.log("Zoomed to: ", zoomedClientRect.toString()); + + } + } + + + private animateFrameHandle: number | null = null; + cancelZoomUpdate() { + if (this.animateFrameHandle) { + window.cancelAnimationFrame(this.animateFrameHandle); + this.animateFrameHandle = null; + } + } + requestZoomUpdate() { + if (this.animateFrameHandle) { + return; // already scheduled + } + this.animateFrameHandle = window.requestAnimationFrame(() => { + this.animateFrameHandle = null; + if (this.normalRef) { + this.updateZoom(this.normalRef); + } + }); + } + private maximizedAnimateFrameHandle: number | null = null; + + cancelMaximizedZoomUpdate() { + if (this.maximizedAnimateFrameHandle) { + window.cancelAnimationFrame(this.maximizedAnimateFrameHandle); + this.maximizedAnimateFrameHandle = null; + } + } + requestMaximizedZoomUpdate() { + if (this.maximizedAnimateFrameHandle) { + return; // already scheduled + } + this.maximizedAnimateFrameHandle = window.requestAnimationFrame(() => { + this.maximizedAnimateFrameHandle = null; + if (this.maximizedRef) { + this.updateMaximizedZoom(); + } + }); + } + + + private zoomButton: HTMLElement | null = null; + private normalRef: HTMLDivElement | null = null; + + setNormalRef(ref: HTMLDivElement | null) { + this.normalRef = ref; + if (ref) { + this.zoomButton = ref.querySelector("#maximize-button") as HTMLDivElement | null; + this.resizeObserver = new ResizeObserver(() => { + this.requestZoomUpdate(); + }); + this.resizeObserver.observe(ref); + let child = ref.firstElementChild as HTMLElement | null; + if (child) { + this.resizeObserver.observe(child); + } + this.requestZoomUpdate(); + } else { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + this.zoomButton = null; + } + this.zoomStartRect = null; + } + } + + + private maximizedRef: HTMLDivElement | null = null; + private maximedResizeObserver: ResizeObserver | null = null; + + setMaximizedRef(ref: HTMLDivElement | null) { + this.maximizedRef = ref; + if (ref) { + this.maximedResizeObserver = new ResizeObserver(() => { + this.requestMaximizedZoomUpdate(); + }); + this.maximedResizeObserver.observe(ref); + let child = ref.firstElementChild as HTMLElement | null; + if (child) { + this.maximedResizeObserver.observe(child); + } + this.requestMaximizedZoomUpdate(); + } else { + if (this.maximedResizeObserver) { + this.maximedResizeObserver.disconnect(); + this.maximedResizeObserver = null; + } + } + } + + private zoomStartRect: Rect | null = null; + + startZoom() { + if (!this.zoomStartRect) { + return; + } + this.props.setShowZoomed(true); + } + + render() { + const classes = withStyles.getClasses(this.props); + void classes; // suppress unused variable warning + const landscape = this.state.screenWidth > this.state.screenHeight; + return (
{ this.setNormalRef(ref); }}> +
+ {!this.props.showZoomed && this.props.children} +
+
+ { + this.startZoom(); + }} + > + + +
+ {this.props.showZoomed && ( + ({ + zIndex: this.props.showZoomed ? 1101 : -1, + overflow: "clip", + background: theme.palette.mode === 'dark' ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.9)' + })} + open={this.props.showZoomed} + > +
{ this.setMaximizedRef(ref); }} + style={{ + position: "absolute", left: 0, top: 0, right: 0, bottom: 0, overflow: "hidden", + visibility: this.props.contentReady ? "visible" : "hidden" + }}> + +
+ { + this.props.showZoomed && this.props.children + } +
+ + { + e.stopPropagation(); + this.props.setShowZoomed(false); + }} + > + + +
+
+ )} +
); + } + }, + styles); + +export default AutoZoom; \ No newline at end of file diff --git a/vite/src/pipedal/ControlViewFactory.tsx b/vite/src/pipedal/ControlViewFactory.tsx index 4d512a9..00cc738 100644 --- a/vite/src/pipedal/ControlViewFactory.tsx +++ b/vite/src/pipedal/ControlViewFactory.tsx @@ -42,7 +42,11 @@ let pluginFactories: IControlViewFactory[] = [ ]; -export function GetControlView(pedalboardItem?: PedalboardItem | null): React.ReactNode { +export function GetControlView( + pedalboardItem: PedalboardItem | null, + showModUi: boolean, + onSetShowModGui: (instanceId: number, showModGui: boolean) => void +): React.ReactNode { let model: PiPedalModel = PiPedalModelFactory.getInstance(); if (!pedalboardItem) { @@ -50,7 +54,13 @@ export function GetControlView(pedalboardItem?: PedalboardItem | null): React.Re } if (pedalboardItem.isStart() || pedalboardItem.isEnd()) { return ( - + { + onSetShowModGui?.(instanceId, showModGui); + }} + /> ); } if (pedalboardItem.isSplit()) { @@ -73,7 +83,11 @@ export function GetControlView(pedalboardItem?: PedalboardItem | null): React.Re } else { return ( - + { + onSetShowModGui?.(instanceId, showModGui); + }} + /> ) } } diff --git a/vite/src/pipedal/FilePropertyDialog.tsx b/vite/src/pipedal/FilePropertyDialog.tsx index b927a33..bd789e4 100644 --- a/vite/src/pipedal/FilePropertyDialog.tsx +++ b/vite/src/pipedal/FilePropertyDialog.tsx @@ -23,8 +23,9 @@ import { createStyles } from './WithStyles'; // import Tone3000Dialog from './Tone3000Dialog'; import Tone3000HelpDialog from './Tone3000HelpDialog'; +import GuitarMLHelpDialog from './GuitarMlHelpDialog'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; -import Link from '@mui/material/Link'; +import LinkEx from './LinkEx'; import DraggableButtonBase from './DraggableButtonBase'; import CloseIcon from '@mui/icons-material/Close'; import { Theme } from '@mui/material/styles'; @@ -69,6 +70,7 @@ import { getAlbumArtUri, getTrackTitle } from './AudioFileMetadata'; const ToobNamModelFileUrl = "http://two-play.com/plugins/toob-nam#modelFile"; +const ToobMlModelFileUrl = "http://two-play.com/plugins/toob-ml#modelFile"; const AUTOSCROLL_TICK_DELAY = 30; const AUTOSCROLL_THRESHOLD = 48; @@ -168,7 +170,8 @@ export interface FilePropertyDialogState { multiSelect: boolean, selectedFiles: string[], //openTone3000Dialog: boolean, - openTone3000Help: boolean + openTone3000Help: boolean, + openGuitarMlHelp: boolean }; @@ -241,12 +244,13 @@ export default withStyles( multiSelect: false, selectedFiles: [], //openTone3000Dialog: false, - openTone3000Help: false + openTone3000Help: false, + openGuitarMlHelp: false }; this.requestScroll = true; } getFullScreen() { - return window.innerWidth < 450 || window.innerHeight < 450; + return document.documentElement.clientWidth < 450 || document.documentElement.clientHeight < 450; } hProgressTimeout: number | null = null; @@ -1057,6 +1061,15 @@ export default withStyles( // } + handleGuitarMlHelp(e: React.MouseEvent) { + e.stopPropagation(); + e.preventDefault(); + + this.setState({ openGuitarMlHelp: true }); + + } + + handleTone3000Help(e: React.MouseEvent) { e.stopPropagation(); e.preventDefault(); @@ -1068,6 +1081,7 @@ export default withStyles( render() { const isTracksDirectory = this.isTracksDirectory(); const isToobNamModelFile = this.props.fileProperty.patchProperty === ToobNamModelFileUrl; + const isToobMLModelFile = this.props.fileProperty.patchProperty === ToobMlModelFileUrl; const classes = withStyles.getClasses(this.props); let columnWidth = this.state.columnWidth; @@ -1487,26 +1501,46 @@ export default withStyles( {(!this.state.reordering && !this.state.multiSelect) && ( <> - + +
+ {isToobNamModelFile && ( +
+ + Download model files from TONE3000 + + { this.handleTone3000Help(e); }} aria-label="help" edge="end" color="inherit" style={{ opacity: 0.6, marginLeft: 8 }} + > + + +
+ + )} + {isToobMLModelFile && ( +
+ + Download model files from GuitarML + + { this.handleGuitarMlHelp(e); }} aria-label="help" edge="end" color="inherit" style={{ opacity: 0.6, marginLeft: 8 }} + > + + +
+ + )} + + {this.state.windowWidth > 500 ? (
- {isToobNamModelFile && ( -
- - Download model files from TONE3000 - - { this.handleTone3000Help(e); }} aria-label="help" edge="end" color="inherit" style={{ opacity: 0.6, marginLeft: 8 }} - > - - -
- - )}
)} +
)} @@ -1702,6 +1737,12 @@ export default withStyles( onClose={() => this.setState({ openTone3000Help: false })} /> )} + {this.state.openGuitarMlHelp && ( + this.setState({ openGuitarMlHelp: false })} + /> + )} ); } diff --git a/vite/src/pipedal/FontTest.tsx b/vite/src/pipedal/FontTest.tsx new file mode 100644 index 0000000..007ddc8 --- /dev/null +++ b/vite/src/pipedal/FontTest.tsx @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React from 'react'; +import Divider from '@mui/material/Divider'; + +function TypeSample(props: { fontFamily: string, cssRef: string, fontWeights: number[] }) { + let { fontFamily, cssRef, fontWeights } = props; + React.useEffect(() => { + let link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = cssRef; + document.head.appendChild(link); + return () => { + document.head.removeChild(link); + }; + }, []); + + return ( +
+ {fontWeights.map((weight) => ( +
+ +
+

{fontFamily} {weight}

+
+
The quick brown fox jumped over the lazy dog.
+
+
+
+ ))} +
+ ); +} + +function FontTest() { + return ( +
+

Font Test

+ {TypeSample({ fontFamily: "EnglandHand", cssRef: "/fonts/england-hand/stylesheet.css", fontWeights: [400] })} + {TypeSample({ fontFamily: "epflul", cssRef: "/fonts/epf/stylesheet.css", fontWeights: [400] })} + {TypeSample({ fontFamily: "Nexa", cssRef: "/fonts/nexa/stylesheet.css", fontWeights: [400] })} + {TypeSample({ fontFamily: "Pirulen", cssRef: "/fonts/pirulen/stylesheet.css", fontWeights: [400] })} + {TypeSample({ fontFamily: "Questrial", cssRef: "/fonts/questrial/stylesheet.css", fontWeights: [400] })} + + +
+ ); +} + +export default FontTest; \ No newline at end of file diff --git a/vite/src/pipedal/FullScreenIME.tsx b/vite/src/pipedal/FullScreenIME.tsx index a9b3930..c762cea 100644 --- a/vite/src/pipedal/FullScreenIME.tsx +++ b/vite/src/pipedal/FullScreenIME.tsx @@ -99,14 +99,14 @@ const FullScreenIME = // because we can't determine the initial window heigth anymore. if (this.currentWidth === undefined) { - this.currentWidth = window.innerWidth; + this.currentWidth = document.documentElement.clientWidth; } - if (this.currentWidth !== window.innerWidth) + if (this.currentWidth !== document.documentElement.clientWidth) { this.validateInput(true); } // eslint-disable-next-line no-restricted-globals - if (window.innerHeight >= this.props.initialHeight) { + if (document.documentElement.clientHeight >= this.props.initialHeight) { this.validateInput(true); } } @@ -194,7 +194,7 @@ const FullScreenIME = this.currentWidth = undefined; return (
); } - this.currentWidth = window.innerWidth; + this.currentWidth = document.documentElement.clientWidth; this.uiControl = control; diff --git a/vite/src/pipedal/GuitarMlHelpDialog.tsx b/vite/src/pipedal/GuitarMlHelpDialog.tsx new file mode 100644 index 0000000..957575a --- /dev/null +++ b/vite/src/pipedal/GuitarMlHelpDialog.tsx @@ -0,0 +1,76 @@ +import DialogEx from "./DialogEx"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import Toolbar from "@mui/material/Toolbar"; +import IconButtonEx from "./IconButtonEx"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import Typography from "@mui/material/Typography"; +import Link from "@mui/material/Link"; + + + +function GuitarMlHelpDialog(props: { + open: boolean; + onClose: () => void; +}) { + const { open, onClose } = props; + return ( + { onClose(); }} + onClose={() => { onClose(); }} + open={open}> + + + + { onClose(); }} + > + + + + + TONE3000 Help + + + + + +
+
+
+ + The GuitarML Tone Library Project provides + a substantial collection of high-quality neural amp models for use in plugins that use the ML + model file format. + + + To use GuitarML models with TooB ML, you must first download the collection of models from the Guitar ML website + by clicking on the link. This should download a file named "Proteus_Tone_Packs.zip" into your browser's Downloads directory. + You must then upload the downloaded zip file, found in your browser's Download directory to the + Pipedal server using the Upload button in PiPedal's file browser dialog. + PiPedal automatically extracts bundles of model files from zip archives, so manual extraction is unnecessary. + + + Please consider sponsoring the GuitarML project in order to support the ongoing development of the Tone Library. + +
+
+
+ + + + + ); +} +export default GuitarMlHelpDialog; \ No newline at end of file diff --git a/vite/src/pipedal/GxTunerView.tsx b/vite/src/pipedal/GxTunerView.tsx index 302860a..2a512e4 100644 --- a/vite/src/pipedal/GxTunerView.tsx +++ b/vite/src/pipedal/GxTunerView.tsx @@ -110,6 +110,9 @@ const GxTunerView = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui= {(instanceId: number, showModGui: boolean) => {}} + />); } }, diff --git a/vite/src/pipedal/IconButtonEx.tsx b/vite/src/pipedal/IconButtonEx.tsx index 6248d4e..8f6b13d 100644 --- a/vite/src/pipedal/IconButtonEx.tsx +++ b/vite/src/pipedal/IconButtonEx.tsx @@ -21,12 +21,13 @@ * SOFTWARE. */ +import React from 'react'; import IconButton, {IconButtonProps} from '@mui/material/IconButton'; import ToolTipEx from './ToolTipEx'; import Typography from "@mui/material/Typography"; interface IconButtonExProps extends IconButtonProps { - tooltip: string; + tooltip: React.ReactElement | string; style?: React.CSSProperties; }; @@ -35,9 +36,10 @@ function IconButtonEx(props: IconButtonExProps) { return ( {tooltip || extra['aria-label'] } - ) + ): (props.tooltip as React.ReactElement) } > diff --git a/vite/src/pipedal/JackStatusView.tsx b/vite/src/pipedal/JackStatusView.tsx index b47d96a..e8530a9 100644 --- a/vite/src/pipedal/JackStatusView.tsx +++ b/vite/src/pipedal/JackStatusView.tsx @@ -70,9 +70,9 @@ export default class JackStatusView extends React.Component {JackHostStatus.getDisplayView("",this.state.jackStatus) }
diff --git a/vite/src/pipedal/JsonAtom.tsx b/vite/src/pipedal/JsonAtom.tsx index 18fd260..330e97f 100644 --- a/vite/src/pipedal/JsonAtom.tsx +++ b/vite/src/pipedal/JsonAtom.tsx @@ -20,118 +20,214 @@ // Utility class for constructing Json atoms. class JsonAtom { - static Property(value: string): any + + static _Bool(value: boolean): any { - return { - "otype_": "Property", - "value": value - }; - } - static Bool(value: boolean): any - { - return { + return new JsonAtom({ "otype_": "Bool", "value": value - }; + }); } - static Int(value: number): any + isBool(): boolean { - return { + return this.isObject() && this.json.otype_ === "Bool"; + } + asBool(): boolean { + if (this.isBool()) { + return this.json.value as boolean; + } + throw new Error("JsonAtom is not a Bool"); + } + static _Int(value: number): JsonAtom + { + return new JsonAtom({ "otype_": "Int", "value": value - }; + }); } - static Long(value: number): any + isInt(): boolean { - return { + return this.isObject() && this.json.otype_ === "Int"; + } + asInt(): number { + if (this.isInt()) { + return this.json.value as number;; + } + throw new Error("JsonAtom is not an Int"); + } + static _Long(value: number): JsonAtom + { + return new JsonAtom({ "otype_": "Long", "value": value - }; + }); } - static Float(value: number): any + + isLong(): boolean { - return value; + return this.isObject() && this.json.otype_ === "Long"; } - static Double(value: number): any + asLong(): number { + if (this.isLong()) { + return this.json.value as number; + } + throw new Error("JsonAtom is not a Long"); + } + static _Float(value: number): JsonAtom { - return { + return new JsonAtom(value); + } + static _Double(value: number): JsonAtom + { + return new JsonAtom({ "otype_": "Double", "value": value - }; + }); } - static String(value: string): any + static _String(value: string): JsonAtom { - return value; + return new JsonAtom(value); } - static Path(value: string): any + isString() : boolean { - return { + return this.json && typeof this.json === 'string'; + } + asString(): string { + if (this.isString()) { + return this.json as string; + } + throw new Error("JsonAtom is not a String"); + } + + static _Path(value: string): JsonAtom + { + return new JsonAtom({ "otype_": "Path", "value": value - }; + }); } - static Uri(value: string): any + isPath(): boolean { - return { + return this.isObject() && this.json.otype_ === "Path"; + } + asPath(): string { + if (this.isPath()) { + return this.json.value as string; + } + throw new Error("JsonAtom is not a Path"); + } + static _Uri(value: string): JsonAtom + { + return new JsonAtom({ "otype_": "URI", "value": value - }; + }); } - static Urid(value: string): any + isUri(): boolean { - return { + return this.isObject() && this.json.otype_ === "URI"; + } + asUri(): string { + if (this.isUri()) { + return this.json.value as string; + } + throw new Error("JsonAtom is not a URI"); + } + static _Urid(value: string): JsonAtom + { + return new JsonAtom({ "otype_": "URID", "value": value - }; + }); } - static Object(type: string): any + isUrid(): boolean { - return { + return this.isObject() && this.json.otype_ === "URID"; + } + asUrid(): string { + if (this.isUrid()) { + return this.json.value as string; + } + throw new Error("JsonAtom is not a URID"); + } + static _Object(type: string): JsonAtom + { + return new JsonAtom({ "otype_": type - }; + }); } - static Tuple(values: any[]): any + isObject(type?: string): boolean { - return { + if (this.json && typeof this.json === 'object' && !Array.isArray(this.json)) { + if (!type) return true; + return type === this.json.otype_; + } + return false; + } + asObject(type?: string): Object { + if (this.isObject(type)) { + return this.json as Object; + } + throw new Error(`JsonAtom is not an Object of type ${type}`); + } + static _Tuple(values: any[]): JsonAtom + { + return new JsonAtom({ "otype_": "Tuple", "value": values - }; + }); } - static IntVector(values: []): any + static _IntVector(values: []): JsonAtom { - return { + return new JsonAtom({ "otype_": "Vector", "vtype_": "Int", "value": values - }; + }); } - static LongVector(values: []): any + static _LongVector(values: []): JsonAtom { - return { + return new JsonAtom({ "otype_": "Vector", "vtype_": "Long", "value": values - }; + }); } - static FloatVector(values: []): any + static _FloatVector(values: []): JsonAtom { - return { + return new JsonAtom({ "otype_": "Vector", "vtype_": "Float", "value": values - }; + }); } - static DoubleVector(values: []): any + static _DoubleVector(values: []): JsonAtom { - return { + return new JsonAtom({ "otype_": "Vector", "vtype_": "Float", "value": values - }; + }); } + constructor(json: any) { + this.json = json; + } + isArray(): boolean { + return this.json && Array.isArray(this.json); + } + isFloat(): boolean { + return this.json && typeof this.json === 'number'; + } + isNull(): boolean { + return this.json === null; + } + asAny(): any { + return this.json; + } + private json: any; }; export default JsonAtom; diff --git a/vite/src/pipedal/LinkEx.tsx b/vite/src/pipedal/LinkEx.tsx new file mode 100644 index 0000000..6b82c4d --- /dev/null +++ b/vite/src/pipedal/LinkEx.tsx @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import Link, {LinkProps} from '@mui/material/Link'; +import {PiPedalModel,PiPedalModelFactory} from './PiPedalModel'; + +export interface LinkExProps extends LinkProps { + +} +function LinkEx(props: LinkExProps) { + const { href, ...rest } = props; + + function handleClick(event: React.MouseEvent) { + const model:PiPedalModel = PiPedalModelFactory.getInstance(); + + // Prevent default behavior if needed + if (model.isAndroidHosted()) { + model.androidHost?.launchExternalUrl(href as string); + } else { + window.open(href as string, '_blank', 'noopener,noreferrer'); + } + event.preventDefault(); + event.stopPropagation(); + } + return ( + )=>{ handleClick(e); }} component="span" + {...rest} /> + + ) +} + +export default LinkEx; \ No newline at end of file diff --git a/vite/src/pipedal/LoadPluginDialog.tsx b/vite/src/pipedal/LoadPluginDialog.tsx index 6d2076c..6661157 100644 --- a/vite/src/pipedal/LoadPluginDialog.tsx +++ b/vite/src/pipedal/LoadPluginDialog.tsx @@ -164,7 +164,8 @@ interface PluginGridProps extends WithStyles { uri?: string; minimumItemWidth?: number; theme: Theme; - open: boolean + open: boolean; + modGuiOnly?: boolean; }; type PluginGridState = { @@ -208,10 +209,10 @@ export const LoadPluginDialog = search_string: "", search_collapsed: true, filterType: filterType_, - client_width: window.innerWidth, - client_height: window.innerHeight, - grid_cell_width: this.getCellWidth(window.innerWidth), - grid_cell_columns: this.getCellColumns(window.innerWidth), + client_width: document.documentElement.clientWidth, + client_height: document.documentElement.clientHeight, + grid_cell_width: this.getCellWidth(document.documentElement.clientWidth), + grid_cell_columns: this.getCellColumns(document.documentElement.clientWidth), minimumItemWidth: props.minimumItemWidth ? props.minimumItemWidth : 220, favoritesList: this.model.favorites.get(), uiPlugins: this.model.ui_plugins.get() @@ -275,9 +276,9 @@ export const LoadPluginDialog = updateWindowSize() { this.setState({ - client_width: window.innerWidth, - client_height: window.innerHeight, - grid_cell_width: this.getCellWidth(window.innerWidth), + client_width: document.documentElement.clientWidth, + client_height: document.documentElement.clientHeight, + grid_cell_width: this.getCellWidth(document.documentElement.clientWidth), grid_cell_columns: this.getCellColumns(window.innerWidth) }); } @@ -526,6 +527,8 @@ export const LoadPluginDialog = for (let i = 0; i < plugins.length; ++i) { let plugin = plugins[i]; + if (this.props.modGuiOnly == true && !plugin.modGui) + continue; try { if (filterType === PluginType.Plugin || rootClass.is_type_of(filterType, plugin.plugin_type)) { let score: number = 0; @@ -720,9 +723,15 @@ export const LoadPluginDialog = style={{ overflowX: "hidden", overflowY: "hidden", display: "flex", flexDirection: "column", flexWrap: "nowrap" }} aria-labelledby="select-plugin-dialog-title">
- -
- { this.cancel(); }} style={{ flex: "0 0 auto" }} > + +
+ { this.cancel(); }} + style={{ flex: "0 0 auto", marginRight: 16 }} > @@ -731,9 +740,9 @@ export const LoadPluginDialog = visibility: (this.state.search_collapsed ? "visible" : "collapse"), width: (this.state.search_collapsed ? undefined : "0px") }}> - {this.state.client_width > 520 ? "Select Plugin" : ""} + {this.state.client_width > 645 ? "Select Plugin" : ""} -
+
-
+
{ this.onFilterChange(e); }} - sx={{ minWidth: 160 }} + sx={{ width: "100%",minWidth: "100%" }} > {this.createFilterOptions()} diff --git a/vite/src/pipedal/Lv2Plugin.tsx b/vite/src/pipedal/Lv2Plugin.tsx index 177b1b2..1256d09 100644 --- a/vite/src/pipedal/Lv2Plugin.tsx +++ b/vite/src/pipedal/Lv2Plugin.tsx @@ -26,10 +26,10 @@ interface Deserializable { } const noteNames: string[] = [ - "C","C#","D","Eb","E","F","F#","G","Ab","A","Bb","B","C" + "C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B", "C" ]; -function semitone12TETValue(value: number) : string { +function semitone12TETValue(value: number): string { let iValue = Math.round(value) % 12; return noteNames[iValue]; @@ -313,10 +313,85 @@ export class UiFileProperty { useLegacyModDirectory: boolean = false; }; + +export class ModGuiPort { + deserialize(input: any): ModGuiPort { + this.index = input.index; + this.symbol = input.symbol; + this.name = input.name; + return this; + } + static deserialize_array(input: any): ModGuiPort[] { + let result: ModGuiPort[] = []; + for (let i = 0; i < input.length; ++i) { + result[i] = new ModGuiPort().deserialize(input[i]); + } + return result; + } + index: number = -1; + symbol: string = ""; + name: string = ""; +}; +export class ModGui { + deserialize(input: any): ModGui { + this.pluginUri = input.pluginUri; + this.resourceDirectory = input.resourceDirectory; + this.iconTemplate = input.iconTemplate ?? ""; + this.settingsTemplate = input.settingsTemplate; + this.javascript = input.javascript; + this.stylesheet = input.stylesheet ?? ""; + this.screenshot = input.screenshot; + this.thumbnail = input.thumbnail; + this.discussionUrl = input.discussionUrl; + this.documentationUrl = input.documentationUrl; + this.brand = input.brand; + this.label = input.label; + this.model = input.model; + this.panel = input.panel; + this.color = input.color; + this.knob = input.knob; + + if (input.ports) { + this.ports = ModGuiPort.deserialize_array(input.ports); + } else { + this.ports = []; + } + return this; + } + static serialize_array(input: any): ModGui[] { + let result: ModGui[] = []; + for (let i = 0; i < input.length; ++i) { + result[i] = new ModGui().deserialize(input[i]); + } + return result; + } + pluginUri = ""; + resourceDirectory = ""; + iconTemplate = ""; + settingsTemplate = ""; + javascript = ""; + stylesheet = ""; + screenshot = ""; + thumbnail = ""; + discussionUrl = ""; + documentationUrl = ""; + brand = ""; + label = ""; + model = ""; + panel = ""; + color = ""; + knob = ""; + ports: ModGuiPort[] = []; + + +}; + export class Lv2Plugin implements Deserializable { deserialize(input: any): Lv2Plugin { this.uri = input.uri; this.name = input.name; + this.minorVersion = input.minorVersion ? input.minorVersion : 0; + this.microVersion = input.microVersion ? input.microVersion : 0; this.brand = input.name ? input.name : ""; this.label = input.label ? input.label : this.name; this.plugin_class = input.plugin_class; @@ -344,12 +419,18 @@ export class Lv2Plugin implements Deserializable { } else { this.uiPortNotifications = []; } + this.modGui = null; + if (input.modGui) { + this.modGui = new ModGui().deserialize(input.modGui); + } return this; } static EmptyFeatures: string[] = []; uri: string = ""; name: string = ""; + minorVersion: number = 0; + microVersion: number = 0; brand: string = ""; label: string = ""; plugin_class: string = ""; @@ -364,6 +445,7 @@ export class Lv2Plugin implements Deserializable { fileProperties: UiFileProperty[] = []; frequencyPlots: UiFrequencyPlot[] = []; uiPortNotifications: UiPropertyNotification[] = []; + modGui: ModGui | null = null; } @@ -459,10 +541,12 @@ export enum ControlType { Vu, Progress, DbVu, - OutputText + OutputText, + + BypassLight } -const textUnits : Set = new Set([ +const textUnits: Set = new Set([ Units.bar, Units.beat, Units.bpm, @@ -478,13 +562,12 @@ const textUnits : Set = new Set([ Units.semitone12TET ]); -function displayUnitAsText(unit: Units): boolean -{ +function displayUnitAsText(unit: Units): boolean { return textUnits.has(unit); } export class UiControl implements Deserializable { -deserialize(input: any): UiControl { + deserialize(input: any): UiControl { this.symbol = input.symbol; this.name = input.name; this.index = input.index; @@ -531,12 +614,11 @@ deserialize(input: any): UiControl { this.controlType = ControlType.Progress; } else if (this.enumeration_property) { this.controlType = ControlType.OutputText; - } else if (displayUnitAsText(this.units)) - { + } else if (displayUnitAsText(this.units)) { this.controlType = ControlType.OutputText } else { this.controlType = ControlType.Vu; - } + } } if (this.isValidEnumeration()) { this.controlType = ControlType.Select; @@ -548,10 +630,8 @@ deserialize(input: any): UiControl { this.controlType = ControlType.OnOffSwitch; } } - if (this.is_input) - { - if (this.pipedal_graphicEq) - { + if (this.is_input) { + if (this.pipedal_graphicEq) { this.controlType = ControlType.GraphicEq; } else if (this.mod_momentaryOnByDefault) { @@ -560,7 +640,7 @@ deserialize(input: any): UiControl { this.controlType = ControlType.Momentary; } else if (this.trigger_property) { this.controlType = ControlType.Trigger; - } + } } return this; } @@ -654,7 +734,7 @@ deserialize(input: any): UiControl { isAbToggle(): boolean { return this.controlType === ControlType.ABSwitch; } - isGraphicEq() : boolean { + isGraphicEq(): boolean { return this.controlType === ControlType.GraphicEq; } isSelect(): boolean { @@ -739,15 +819,12 @@ deserialize(input: any): UiControl { } } - if (this.units === Units.s) - { + if (this.units === Units.s) { let iValue = Math.round(value); - if (iValue >= 60) - { - let minutes = Math.floor(iValue/60); + if (iValue >= 60) { + let minutes = Math.floor(iValue / 60); let seconds = iValue % 60; - if (seconds < 10) - { + if (seconds < 10) { return minutes + ":0" + seconds; } @@ -756,11 +833,10 @@ deserialize(input: any): UiControl { return this.formatShortValue(value); } } - if (this.units === Units.semitone12TET) - { + if (this.units === Units.semitone12TET) { return semitone12TETValue(value); } - + let text = this.formatShortValue(value); switch (this.units) { @@ -831,11 +907,45 @@ deserialize(input: any): UiControl { } +export class Lv2PatchPropertyInfo { + deserialize(input: any): Lv2PatchPropertyInfo { + this.uri = input.uri; + this.writable = input.writable; + this.readable = input.readable; + this.label = input.label; + this.index = input.index; + this.type = input.type; + this.comment = input.comment; + this.shortName = input.shortName; + this.fileTypes = input.fileTypes; + this.supportedExtensions = input.supportedExtensions; + return this; + } + static deserialize_array(input: any): Lv2PatchPropertyInfo[] { + let result: Lv2PatchPropertyInfo[] = []; + for (let i = 0; i < input.length; ++i) { + result[i] = new Lv2PatchPropertyInfo().deserialize(input[i]); + } + return result; + } + uri: string = ""; + writable : boolean = false; + readable: boolean = false; + label: string = ""; + index: number = -1; + type: string = ""; + comment: string = ""; + shortName: string = ""; + fileTypes: string[] = []; + supportedExtensions: string[] = []; +}; export class UiPlugin implements Deserializable { deserialize(input: any): UiPlugin { this.uri = input.uri; this.name = input.name; + this.minorVersion = input.minorVersion ? input.minorVersion : 0; + this.microVersion = input.microVersion ? input.microVersion : 0; this.brand = input.brand ? input.brand : ""; this.label = input.label ? input.label : this.name; this.plugin_type = input.plugin_type as PluginType; @@ -861,6 +971,11 @@ export class UiPlugin implements Deserializable { } this.is_vst3 = input.is_vst3; + this.modGui = null; + if (input.modGui) { + this.modGui = new ModGui().deserialize(input.modGui); + } + this.patchProperties = Lv2PatchPropertyInfo.deserialize_array(input.patchProperties ?? []); return this; } @@ -906,8 +1021,21 @@ export class UiPlugin implements Deserializable { return null; } + getFilePropertyByUri(patchPropertyUri: string): UiFileProperty | null { + for (let i = 0; i < this.fileProperties.length; ++i) + { + let fileProperty = this.fileProperties[i]; + if (fileProperty.patchProperty === patchPropertyUri) { + return fileProperty; + } + } + return null; + } + uri: string = ""; name: string = ""; + minorVersion: number = 0; + microVersion: number = 0; brand: string = ""; label: string = ""; plugin_type: PluginType = PluginType.InvalidPlugin; @@ -924,6 +1052,8 @@ export class UiPlugin implements Deserializable { fileProperties: UiFileProperty[] = []; frequencyPlots: UiFrequencyPlot[] = []; is_vst3: boolean = false; + modGui: ModGui | null = null; // null if no mod gui. + patchProperties: Lv2PatchPropertyInfo[] = []; } diff --git a/vite/src/pipedal/MainPage.tsx b/vite/src/pipedal/MainPage.tsx index 85ca9e8..e7ba524 100644 --- a/vite/src/pipedal/MainPage.tsx +++ b/vite/src/pipedal/MainPage.tsx @@ -58,9 +58,12 @@ import Snapshot3Icon from "./svg/snapshot_3.svg?react"; import Snapshot4Icon from "./svg/snapshot_4.svg?react"; import Snapshot5Icon from "./svg/snapshot_5.svg?react"; import Snapshot6Icon from "./svg/snapshot_6.svg?react"; +import ModUiIcon from './svg/mod_ui.svg?react'; +import PipedalUiIcon from './svg/pp_ui.svg?react'; import SnapshotDialog from './SnapshotDialog'; import { css } from '@emotion/react'; +import { setDefaultModGuiPreference } from './ModGuiHost'; const SPLIT_CONTROLBAR_THRESHHOLD = 750; @@ -79,7 +82,7 @@ const styles = ({ palette }: Theme) => { }), pedalboardScroll: css({ position: "relative", width: "100%", - flex: "0 0 auto", overflow: "auto", maxHeight: 220 + flex: "0 0 auto", overflow: "auto", maxHeight: 220, }), pedalboardScrollSmall: css({ position: "relative", width: "100%", @@ -97,7 +100,7 @@ const styles = ({ palette }: Theme) => { flex: "0 0 64px", width: "100%", paddingLeft: 24, paddingRight: 16, paddingBottom: 16 }), controlContent: css({ - flex: "1 1 auto", width: "100%", overflowY: "hidden", minHeight: 300 + flex: "1 1 auto", width: "100%", overflowY: "hidden", minHeight: 185 }), controlContentSmall: css({ flex: "0 0 162px", width: "100%", height: 162, overflowY: "hidden", @@ -128,6 +131,8 @@ interface MainState { showMidiBindingsDialog: boolean; screenHeight: number; displayNameDialogOpen: boolean; + showModUi: boolean; + } @@ -140,7 +145,7 @@ export const MainPage = model: PiPedalModel; getSplitToolbar() { - return this.windowSize.width < SPLIT_CONTROLBAR_THRESHHOLD; + return this.windowSize.width < SPLIT_CONTROLBAR_THRESHHOLD && this.windowSize.height >= HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK; } getDisplayAuthor() { if (this.getSplitToolbar()) { @@ -153,7 +158,10 @@ export const MainPage = super(props); this.model = PiPedalModelFactory.getInstance(); let pedalboard = this.model.pedalboard.get(); - let selectedPedal = pedalboard.getFirstSelectableItem(); + let selectedPedal = pedalboard.selectedPlugin; + if (selectedPedal === -1) { + selectedPedal = pedalboard.getFirstSelectableItem(); + } this.state = { selectedPedal: selectedPedal, @@ -167,7 +175,8 @@ export const MainPage = displayAuthor: this.getDisplayAuthor(), horizontalScrollLayout: this.windowSize.height < HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK, showMidiBindingsDialog: false, - screenHeight: this.windowSize.height + screenHeight: this.windowSize.height, + showModUi: false }; @@ -237,17 +246,25 @@ export const MainPage = this.model.loadPluginPreset(instanceId, presetInstanceId); } onPedalboardChanged(value: Pedalboard) { - let selectedItem = -1; - if (this.state.selectedPedal === Pedalboard.START_CONTROL || this.state.selectedPedal === Pedalboard.END_CONTROL) { - selectedItem = this.state.selectedPedal; - } else if (value.hasItem(this.state.selectedPedal)) { - selectedItem = this.state.selectedPedal; - } else { - selectedItem = value.getFirstSelectableItem(); + // let selectedItem = -1; + // if (this.state.selectedPedal === Pedalboard.START_CONTROL || this.state.selectedPedal === Pedalboard.END_CONTROL) { + // selectedItem = this.state.selectedPedal; + // } else if (value.hasItem(this.state.selectedPedal)) { + // selectedItem = this.state.selectedPedal; + // } else { + // selectedItem = value.getFirstSelectableItem(); + // } + let selectedItem = value.selectedPlugin; + if (selectedItem !== -2 && selectedItem !== -3) // start and end nodes. + { + if (selectedItem === -1 || !value.hasItem(selectedItem)) { + selectedItem = value.getFirstSelectableItem(); + } } this.setState({ pedalboard: value, selectedPedal: selectedItem, + showModUi: value.maybeGetItem(selectedItem)?.useModUi ?? false, selectedSnapshot: value.selectedSnapshot }); } @@ -258,9 +275,10 @@ export const MainPage = } onDeletePedal(instanceId: number): void { + // ok. let result = this.model.deletePedalboardPedal(instanceId); - if (result != null) { - this.setState({ selectedPedal: result }); + if (result != null) + this.setSelection(result); { } } @@ -274,6 +292,14 @@ export const MainPage = this.model.pedalboard.removeOnChangedHandler(this.onPedalboardChanged); super.componentWillUnmount(); } + + componentDidUpdate(prevProps: MainProps, prevState: MainState) { + if (prevState.selectedPedal !== this.state.selectedPedal + || prevState.pedalboard !== this.state.pedalboard + ) { + this.setState({ showModUi: this.state.pedalboard.maybeGetItem(this.state.selectedPedal)?.useModUi ?? false }); + } + } updateResponsive() { this.setState({ splitControlBar: this.getSplitToolbar(), @@ -290,6 +316,10 @@ export const MainPage = setSelection(selectedId_: number): void { this.setState({ selectedPedal: selectedId_ }); + if (this.state.pedalboard.selectedPlugin !== selectedId_) { + this.model.setPedalboardSelectedPlugin(selectedId_); + this.state.pedalboard.selectedPlugin = selectedId_; + } } onSelectionChanged(selectedId: number): void { @@ -320,7 +350,7 @@ export const MainPage = this.setState({ loadDialogOpen: false }); let itemId = this.state.selectedPedal; let newSelectedItem = this.model.loadPedalboardPlugin(itemId, selectedUri); - this.setState({ selectedPedal: newSelectedItem }); + this.setSelection(newSelectedItem); } onSnapshotDialogOk(): void { this.setState({ snapshotDialogOpen: false }); @@ -364,6 +394,15 @@ export const MainPage = } + handleShowModUi() { + let newState = !this.state.showModUi; + this.model.setPedalboardItemUseModUi(this.state.selectedPedal, newState); + let item = this.model.pedalboard.get().maybeGetItem(this.state.selectedPedal); + if (item) { + setDefaultModGuiPreference(item.uri, newState); + } + } + onPedalboardPropertyChanged(instanceId: number, key: string, value: number) { this.model.setPedalboardControl(instanceId, key, value); } @@ -375,13 +414,14 @@ export const MainPage = if (pedalboardItem === null) return ""; return pedalboardItem.uri; } - titleBar(pedalboardItem: PedalboardItem | null): React.ReactNode { + titleBar(pedalboardItem: PedalboardItem | null, canShowModUi: boolean): React.ReactNode { let title = ""; let author = ""; let infoPluginUri = ""; let presetsUri = ""; let missing = false; let canEditTitle = false; + let modGuiButtonVisible = false; if (pedalboardItem) { if (pedalboardItem.isEmpty()) { title = ""; @@ -411,6 +451,7 @@ export const MainPage = // if (uiPlugin.description.length > 20) { // } infoPluginUri = uiPlugin.uri; + modGuiButtonVisible = true; } } } @@ -437,23 +478,28 @@ export const MainPage = display: "flex", flexDirection: "row", height: 48, flexWrap: "nowrap", alignItems: "center" }}> -
+
{canEditTitle ? ( { + style={{ + flex: "0 1 auto", borderRadius: "6px", width: "100%", paddingLeft: 8, paddingRight: 8, height: 48, + textTransform: "none", textAlign: "left", justifyItems: "start", overflow: "clip" + }} + onClick={() => { this.handleEditPluginDisplayName(); }} > - - {title} - {this.state.displayAuthor && ( - {author} - )} - +
+ + {title} + {this.state.displayAuthor && ( + {author} + )} + +
) : ( -
+
{title} {this.state.displayAuthor && ( {author} @@ -469,6 +515,39 @@ export const MainPage =
+ + {modGuiButtonVisible && ( +
+ + + {this.state.showModUi ? "PiPedal UI" : "MOD UI"} + + Use MOD UI or PiPedal UI for plugin. + {!canShowModUi && " The current plugin does not provide a MOD user interface."} + +
+ + )} + onClick={() => { + this.handleShowModUi() + }} + size="large"> + {(!this.state.showModUi || !canShowModUi) ? + ( + + ) : ( + + ) + } + +
+ + )} +
); } @@ -494,6 +573,15 @@ export const MainPage = } } + canShowModUi(pedalboardItem: PedalboardItem): boolean { + let pluginInfo = this.model.getUiPlugin(pedalboardItem.uri); + if (pluginInfo === null) { + return false; + } + return pluginInfo.modGui !== null; + + } + render() { const classes = withStyles.getClasses(this.props); let pedalboard = this.model.pedalboard.get(); @@ -508,8 +596,9 @@ export const MainPage = let instanceId = -1; let missing = false; let pluginUri = "#error"; - + let canShowModUi = false; if (pedalboardItem) { + canShowModUi = this.canShowModUi(pedalboardItem); canDelete = pedalboard.canDeleteItem(pedalboardItem.instanceId); instanceId = pedalboardItem.instanceId; if (pedalboardItem.isEmpty()) { @@ -570,7 +659,7 @@ export const MainPage =
{ (!this.state.splitControlBar || !this.props.enableStructureEditing) - && this.titleBar(pedalboardItem) + && this.titleBar(pedalboardItem, canShowModUi) }
@@ -642,7 +731,7 @@ export const MainPage = this.state.splitControlBar && this.props.enableStructureEditing && (
{ - this.titleBar(pedalboardItem) + this.titleBar(pedalboardItem, canShowModUi) }
) @@ -657,7 +746,12 @@ export const MainPage = ) : ( - GetControlView(pedalboardItem) + GetControlView(pedalboardItem, this.state.showModUi && canShowModUi, + (instanceId: number, showModGui: boolean) => { + this.model.setPedalboardItemUseModUi(instanceId, showModGui) + this.setState({ showModUi: showModGui }); + } + ) ) }
@@ -689,7 +783,7 @@ export const MainPage = this.setState({ displayNameDialogOpen: false }); }} /> - )} + )}
); } diff --git a/vite/src/pipedal/MidiBindingView.tsx b/vite/src/pipedal/MidiBindingView.tsx index ad4e554..861cfb2 100644 --- a/vite/src/pipedal/MidiBindingView.tsx +++ b/vite/src/pipedal/MidiBindingView.tsx @@ -58,13 +58,13 @@ export enum MidiControlType { } export function getMidiControlType(uiPlugin: UiPlugin | undefined, symbol: string): MidiControlType { + if (symbol === "__bypass") { + return MidiControlType.Toggle; + } if (!uiPlugin) return MidiControlType.None; let port = uiPlugin.getControl(symbol); if (!port) return MidiControlType.None; - if (symbol === "__bypass") { - return MidiControlType.Toggle; - } if (!port) return MidiControlType.None; diff --git a/vite/src/pipedal/MidiChannelBinding.tsx b/vite/src/pipedal/MidiChannelBinding.tsx index 76feeeb..06dab83 100644 --- a/vite/src/pipedal/MidiChannelBinding.tsx +++ b/vite/src/pipedal/MidiChannelBinding.tsx @@ -24,6 +24,11 @@ export enum MidiDeviceSelection { DeviceList = 2 } +// This feature is not yet completely implemented. +export const midiChannelBindingControlFeatureEnabled: boolean = false; // set to true to enable the midi channel binding control in the plugin control view. + + + export default class MidiChannelBinding { deserialize(input: any) : MidiChannelBinding { this.deviceSelection = input.deviceSelection; diff --git a/vite/src/pipedal/ModGuiErrorBoundary.tsx b/vite/src/pipedal/ModGuiErrorBoundary.tsx new file mode 100644 index 0000000..476a152 --- /dev/null +++ b/vite/src/pipedal/ModGuiErrorBoundary.tsx @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { Component, ErrorInfo, ReactNode } from "react"; +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; + +import { UiPlugin } from "./Lv2Plugin"; + +interface Props { + plugin: UiPlugin | null; + children?: ReactNode; + onClose: () => void; +} + +interface State { + hasError: boolean; + message: string; +} + +class ModGuiErrorBoundary extends Component { + public state: State = { + hasError: false, + message: "", + }; + + public static getDerivedStateFromError(error: Error): State { + // Update state so the next render will show the fallback UI. + return { hasError: true, message: error.message }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("ModGUI rendering error: ", error); + console.error("Stack trace: " , errorInfo.componentStack); + } + + public render() { + if (this.state.hasError) { + return ( +
+
+
+
+
+ + { this.props.plugin ? + `Error Loading '${this.props.plugin.name}'` : + `Error Loading UI` + } + + + + {this.state.message} + +
+ +
+
+
+
+
+ +
+ + ); + } + + return this.props.children; + } +} + +export default ModGuiErrorBoundary; diff --git a/vite/src/pipedal/ModGuiHost.tsx b/vite/src/pipedal/ModGuiHost.tsx new file mode 100644 index 0000000..cd58a2a --- /dev/null +++ b/vite/src/pipedal/ModGuiHost.tsx @@ -0,0 +1,1749 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { UiPlugin, UiControl, ControlType, UiFileProperty } from './Lv2Plugin'; +import React from 'react'; +import CloseIcon from '@mui/icons-material/Close'; +import IconButtonEx from './IconButtonEx'; +import Typography from '@mui/material/Typography/Typography'; +import ModGuiErrorBoundary from './ModGuiErrorBoundary'; +import OkDialog from './OkDialog'; +import { pathFileName, pathParentDirectory } from './FileUtils'; +import JsonAtom from './JsonAtom'; + +import { + PiPedalModel, PiPedalModelFactory, MonitorPortHandle, State, + ListenHandle, FileRequestResult +} from './PiPedalModel'; + + +const RANGE_SCALE = 120; // 120 pixels to move from 0 to 1. +const FINE_RANGE_SCALE = RANGE_SCALE * 10; // 1200 pixels to move from 0 to 1. +const ULTRA_FINE_RANGE_SCALE = RANGE_SCALE * 50; // 12000 pixels to move from 0 to 1. + +const WHEEL_RANGE_SCALE = 120 * 20; +const FINE_WHEEL_RANGE_SCALE = WHEEL_RANGE_SCALE * 10; +const ULTRA_FINE_WHEEL_RANGE_SCALE = WHEEL_RANGE_SCALE * 50; + + +export type BypassChangedCallback = (value: boolean) => void; + +function HtmlEncode(value: string): string { + value = value.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + return value; + +} + +function htmlEncodeTest() { + let testString = "&<>'\"\\"; + + let encoded = HtmlEncode(testString); + let div = document.createElement("div"); + div.innerHTML = `
${encoded}
`; + let innerDiv = div.children[0] as HTMLDivElement; + if (innerDiv.getAttribute("data-text") !== testString) { + throw new Error("HtmlEncode failed to encode properly: " + encoded); + } + +} + +htmlEncodeTest(); + +function PathToString(json: any): string { + let t = new JsonAtom(json); + if (t.isPath()) { + return t.asPath(); + } + if (t.isString()) { + return t.asString(); + } + return ""; +} + + +function StringToPath(path: string): any { + return JsonAtom._Path(path).asAny(); +} + + +export interface ModGuiHostProps { + instanceId: number, + plugin: UiPlugin; + onClose: () => void; + width?: number; + height?: number; + test?: boolean; + handleFileSelect: (instanceId: number, propertyUri: string, filePath: string) => void; + onContentReady: (ready: boolean) => void; + + +} +export interface ModGuiJs { + set_port_value: (symbol: string, value: number) => void; + patch_get: (uri: string) => any; + patch_set: (uri: string, valuetype: string, value: any) => void; + get_custom_resource_filename: (filename: string) => string; + get_port_index_for_symbol: (symbol: string) => number; + get_port_symbol_for_index: (index: number) => string | null; +}; + +export interface ModGuiControl { + + //setControlValue: (value: number) => void; + onMounted: () => void; + onUnmount: () => void; + render(): HTMLElement | null; +}; + +interface CustomSelectPathControlProps { + instanceId: number; + plugin: UiPlugin; + propertyUri: string, + hostSite: PiPedalModel; + + handleFileSelect: (instanceId: number, propertyUri: string, filePath: string) => void; + +}; + + +class CustomSelectPathControl implements ModGuiControl { + + private props: CustomSelectPathControlProps; + private frameElement?: HTMLElement; + + private animationFrameId: number | null = null; + + constructor(props: CustomSelectPathControlProps) { + this.props = props; + } + + private isInlineList: boolean = false; + + requestUpdate() { + if (!this.mounted) return; + if (!this.frameElement) { + return; + } + + + if (this.animationFrameId === null) { + this.animationFrameId = window.requestAnimationFrame(() => { + this.animationFrameId = null; + this.updateImage(); + }); + } + } + cancelRequestUpdate() { + let animationFrameId = this.animationFrameId; + if (animationFrameId !== null) { + window.cancelAnimationFrame(animationFrameId); + this.animationFrameId = null; + } + } + + private getFileProperty(): UiFileProperty | null { + for (let property of this.props.plugin.fileProperties) { + if (property.patchProperty === this.props.propertyUri) { + return property; + } + } + return null; + } + + navBreadcrumbText(breadcrumbs: { pathname: string, displayName: string }[]): string { + let breadcrumbText = ""; + for (let i = 0; i < breadcrumbs.length - 1; ++i) { + let breadcrumb = breadcrumbs[i]; + breadcrumbText += " / "; + } + let breadcrumb = breadcrumbs[breadcrumbs.length - 1]; + breadcrumbText += "
" + + HtmlEncode(breadcrumb.displayName) + + "
"; + return breadcrumbText; + } + + makeEnumeratedListItem( + type: string, + basename: string, + fullname: string, + encodebaseName?: boolean + ): HTMLElement { + type = HtmlEncode(type); + fullname = HtmlEncode(fullname); + if (encodebaseName !== false) { + basename = HtmlEncode(basename); + } + let itemHtml = this.enumeratedListItemTemplate + .replace(/{{basename}}/g, basename) + .replace(/{{filetype}}/g, type) + .replace(/{{fullname}}/g, fullname); + let t = document.createElement("div"); + t.innerHTML = itemHtml; + let itemElement = t.firstElementChild as HTMLElement; + return itemElement; + + } + + fileRequestResult: FileRequestResult | null = null; + + async requestDirectoryUpdate() { + let model = PiPedalModelFactory.getInstance(); + let fileProperty = this.getFileProperty(); + if (!fileProperty) { + return; + } + + let files = await model.requestFileList2(this.navDirectory || "", fileProperty); + this.fileRequestResult = files; + if (!this.mounted) { + return; + } + if (this.enumeratedListContainerElement) { + this.enumeratedListContainerElement.innerHTML = ""; // remove all children. + + if (files.breadcrumbs.length >= 2) { + let parentDirectory = files.breadcrumbs[files.breadcrumbs.length - 2].pathname; + + let dotdotHtml = "
" + + "
[ ../ ]
" + + "
" + + this.navBreadcrumbText(files.breadcrumbs) + + "
"; + + let dotdotElement = this.makeEnumeratedListItem( + "directory", + dotdotHtml, + parentDirectory, + false + ); + dotdotElement.style.setProperty("border-bottom", "1px solid #888"); + this.enumeratedListContainerElement.appendChild(dotdotElement); + } else { + + // mod-role="enumeration-option" mod-filetype="{{filetype}}" mod-parameter-value="{{fullname}}" + let dotdotHtml = "
" + + "
 
" + + "
" + + this.navBreadcrumbText(files.breadcrumbs) + + "
"; + + let dotdotElement = this.makeEnumeratedListItem( + "directory", + dotdotHtml, + "", + false + ); + dotdotElement.style.setProperty("border-bottom", "1px solid #888"); + this.enumeratedListContainerElement.appendChild(dotdotElement); + + } + + for (let file of files.files) { + let displayName = file.displayName; + if (file.isDirectory) { + displayName = '[ ' + displayName + '/ ]'; + } + let fileType = file.isDirectory ? "directory" : "file"; + let itemElement = this.makeEnumeratedListItem( + fileType, + displayName, + file.pathname + ); + this.enumeratedListContainerElement.appendChild(itemElement); + } + this.updateSelection(); + } + + } + + private navDirectory: string | null = null; + + setNavDirectory(navDirectory: string | null) { + if (this.navDirectory !== navDirectory) { + this.navDirectory = navDirectory; + this.requestDirectoryUpdate(); + } + } + private pathValue: string | null = null; + setPathValue(value: string) { + if (this.pathValue !== value) { + this.pathValue = value; + if (this.pathValue !== "" || this.navDirectory === null) { + let browsePath = pathParentDirectory(value); + this.setNavDirectory(browsePath); + } + this.requestUpdate(); + } + if (this.enumeratedValueElement) { + this.enumeratedValueElement.textContent = pathFileName(this.pathValue); + } + } + + private updateImage() { + if (!this.frameElement || !this.mounted) { + return; + } + + // this.frameElement.querySelectorAll('[.mod_enumerated_list]') + // .forEach((inputControl: Element) => { + + // (inputControl as HTMLElement).textContent = text; + // }); + // this.frameElement.querySelectorAll('[mod-role=enumeration-option]') + // .forEach((inputControl: Element) => { + // let strValue = inputControl.getAttribute("mod-port-value"); + // let value = parseFloat(strValue || ""); + // inputControl.classList.remove("selected"); + // if (value === this.value) { + // inputControl.classList.add("selected"); + // } + + // (inputControl as HTMLElement).textContent = text; + // }); + //this.frameElement.textContent = text; + + } + + handlePropertyValueChange(value: any) { + let path: string = PathToString(value); + this.setPathValue(path); + } + private mounted: boolean = false; + + private listenHandle: ListenHandle | null = null; + onMounted() { + this.mounted = true; + this.props.hostSite.monitorPatchProperty(this.props.instanceId, this.props.propertyUri, + (instanceId, propertyUri, value) => { + this.handlePropertyValueChange(value); + } + ); + this.props.hostSite.getPatchProperty(this.props.instanceId, this.props.propertyUri) + .then((value: any) => { + this.handlePropertyValueChange(value); + }). + catch((error: any) => { + if (error instanceof Error) { + console.error((error as Error).message); + } else { + console.error(error.toString()); + } + this.handlePropertyValueChange(""); + }); + this.requestUpdate(); + } + + onUnmount() { + if (this.listenHandle) { + this.props.hostSite.cancelMonitorPatchProperty(this.listenHandle); + this.listenHandle = null; + } + this.mounted = false; + this.cancelRequestUpdate(); + } + + + render(): HTMLElement | null { + return null; + } + + toggleVisibility(element: HTMLElement) { + element.classList.toggle("hidden"); + let display = element.style.display; + if (display === "none" || display === "") { + element.style.display = "block"; + } else { + element.style.display = "none"; + } + } + + + getValueElement(): HTMLElement | null { + return null; + } + + updateSelection() { + if (!this.frameElement) { + return; + } + let valueElement = this.getValueElement(); + if (valueElement) { + if (this.pathValue === null || this.pathValue === "") { + valueElement.textContent = "No file selected"; + return; + } + valueElement.textContent = this.pathValue; + } + if (this.isInlineList && this.enumeratedListContainerElement) { + for (let i = 0; i < this.enumeratedListContainerElement.children.length; i++) { + let child = this.enumeratedListContainerElement.children[i]; + let strValue = child.getAttribute("mod-parameter-value"); + let strType = child.getAttribute("mod-filetype"); + if (strValue === null) return; + if (strValue === this.pathValue && strType !== "directory") { + child.classList.add("selected"); + } else { + child.classList.remove("selected"); + } + }; + } + } + handleClick(event: MouseEvent) { + if (!this.frameElement) { + return; + } + event.preventDefault(); + event.stopPropagation(); + + let valueElement = this.getValueElement(); + + if (event.target === valueElement) { + // A click on the value element, so launch the file browser. hooboy! + return; + } + if (!event.target) { + return; + } + let target = event.target as HTMLElement; + if (target.getAttribute("mod-role") === "enumeration-option") { + let strValue = target.getAttribute("mod-parameter-value"); + if (strValue === null) return; let strType = target.getAttribute("mod-filetype"); + if (strType === "directory") { + this.setNavDirectory(strValue); + return; + } + + this.setPathValue(strValue); + this.updateSelection(); + this.props.hostSite.setPatchProperty(this.props.instanceId, this.props.propertyUri, StringToPath(strValue)); + return; + } + } + + private enumeratedListContainerElement: HTMLElement | null = null; + private enumeratedListItemTemplate: string = ""; + private enumeratedValueElement: HTMLElement | null = null; + + getValueControlElement(): HTMLElement | null { + if (!this.frameElement) { + return null; + } + return this.frameElement.querySelector('[mod-role=input-parameter-value]') as HTMLElement | null; + } + + async handleValueClick(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + + this.props.handleFileSelect( + this.props.instanceId, + this.props.propertyUri, + this.pathValue || "" + ); + } + + attach(frameElement: HTMLElement) { + + this.frameElement = frameElement; + this.requestUpdate(); + this.frameElement.onclick = (event: MouseEvent) => { this.handleClick(event); } + + // swipe the enumeraation-option generated by the template. + // We'll use it as a template to create new file entries. + let enumeratedList = this.frameElement.querySelector(".mod-enumerated-list"); + if (enumeratedList) { + this.enumeratedListContainerElement = enumeratedList as HTMLElement; + if (this.enumeratedListContainerElement.children.length === 1) { + let firstChild = this.enumeratedListContainerElement.children[0]; + if (firstChild) { + this.enumeratedListItemTemplate = firstChild.outerHTML; + this.enumeratedListContainerElement.removeChild(firstChild); + } + } + } + this.enumeratedValueElement = this.getValueControlElement(); + + this.isInlineList = this.enumeratedValueElement === null; + if (this.enumeratedValueElement) { + this.enumeratedValueElement.onclick = (event: MouseEvent) => { + this.handleValueClick(event); + } + } + + + + } +}; + + +interface CustomSelectControlProps { + instanceId: number; + pluginControl: UiControl; + onValueChanged: (instanceId: number, symbol: string, value: number) => void; + monitorPort: ( + instanceId: number, + symbol: string, + interval: number, + callback: (value: number) => void) => MonitorPortHandle; + unmonitorPort: (handle: MonitorPortHandle) => void; +}; + +class CustomSelectControl implements ModGuiControl { + + private props: CustomSelectControlProps; + private frameElement?: HTMLElement; + + private value: number = -1.038327E-15 + private animationFrameId: number | null = null; + + constructor(props: CustomSelectControlProps) { + this.props = props; + } + + + requestUpdate() { + if (!this.mounted) return; + if (!this.frameElement) { + return; + } + + + if (this.animationFrameId === null) { + this.animationFrameId = window.requestAnimationFrame(() => { + this.animationFrameId = null; + this.updateImage(); + }); + } + } + cancelRequestUpdate() { + let animationFrameId = this.animationFrameId; + if (animationFrameId !== null) { + window.cancelAnimationFrame(animationFrameId); + this.animationFrameId = null; + } + } + setControlValue(value: number) { + if (this.value !== value) { + this.value = value; + this.requestUpdate(); + } + } + + private updateImage() { + if (!this.frameElement || !this.mounted) { + return; + } + let text: string = ""; + let bestError = 1E308; + for (let scalePoint of this.props.pluginControl.scale_points) { + let error = Math.abs(scalePoint.value - this.value); + if (error < bestError) { + bestError = error; + text = scalePoint.label; + } + } + + this.frameElement.querySelectorAll('[mod-role=input-control-value]') + .forEach((inputControl: Element) => { + + (inputControl as HTMLElement).textContent = text; + }); + this.frameElement.querySelectorAll('[mod-role=enumeration-option]') + .forEach((inputControl: Element) => { + let strValue = inputControl.getAttribute("mod-port-value"); + let value = parseFloat(strValue || ""); + inputControl.classList.remove("selected"); + if (value === this.value) { + inputControl.classList.add("selected"); + } + + (inputControl as HTMLElement).textContent = text; + }); + //this.frameElement.textContent = text; + + } + private mounted: boolean = false; + + private monitorHandle: MonitorPortHandle | null = null; + onMounted() { + this.mounted = true; + this.monitorHandle = this.props.monitorPort( + this.props.instanceId, + this.props.pluginControl.symbol, + 1.0 / 15, + (value: number) => { + if (value != this.value) { + this.setControlValue(value); + } + } + ); + this.requestUpdate(); + } + + onUnmount() { + if (this.monitorHandle) { + this.props.unmonitorPort(this.monitorHandle); + this.monitorHandle = null; + } + this.mounted = false; + this.cancelRequestUpdate(); + } + + + render(): HTMLElement | null { + return null; + } + + toggleVisibility(element: HTMLElement) { + element.classList.toggle("hidden"); + let display = element.style.display; + if (display === "none" || display === "") { + element.style.display = "block"; + } else { + element.style.display = "none"; + } + } + handleClick(event: MouseEvent) { + if (!this.frameElement) { + return; + } + event.preventDefault(); + event.stopPropagation(); + let enumeratedList = this.frameElement.querySelector(".mod-enumerated-list"); + if (enumeratedList) { + let element = enumeratedList as HTMLElement; + this.toggleVisibility(element) + } + if (event.target) { + let target = event.target as HTMLElement; + let role = target.getAttribute("mod-role"); + if (role === "enumeration-option") // a click in the drop-down list? + { + let strValue = target.getAttribute("mod-port-value"); + if (strValue && strValue !== "") { + let value = parseFloat(strValue); + if (!isNaN(value)) { + this.setControlValue(value); + this.props.onValueChanged(this.props.instanceId, this.props.pluginControl.symbol, value); + } + } + + } + } + } + + attach(frameElement: HTMLElement) { + + this.frameElement = frameElement; + this.requestUpdate(); + this.frameElement.onclick = (event: MouseEvent) => { this.handleClick(event); } + } +}; + + +interface BypassLightControlProps { + instanceId: number; + model: PiPedalModel; +}; + +function ModMessage(message: string) { + return "Failed to generate UI. (" + message + ")"; +} + +class BypassLightControl implements ModGuiControl { + + private props: BypassLightControlProps; + private frameElement?: HTMLElement; + + private value: number = -1.038327E-15 + private animationFrameId: number | null = null; + + constructor(props: BypassLightControlProps) { + this.props = props; + } + + + requestUpdate() { + if (!this.mounted) return; + if (!this.frameElement) { + return; + } + + + if (this.animationFrameId === null) { + this.animationFrameId = window.requestAnimationFrame(() => { + this.animationFrameId = null; + this.updateImage(); + }); + } + } + cancelRequestUpdate() { + let animationFrameId = this.animationFrameId; + if (animationFrameId !== null) { + window.cancelAnimationFrame(animationFrameId); + this.animationFrameId = null; + } + } + setControlValue(value: number) { + if (this.value !== value) { + this.value = value; + this.requestUpdate(); + } + } + + private updateImage() { + if (!this.frameElement || !this.mounted) { + return; + } + + this.frameElement.classList.remove('on', 'off'); + this.frameElement.classList.add( + this.value ? + 'on' : 'off' + ); + + } + private mounted: boolean = false; + + private listenHandle: ListenHandle | null = null; + onMounted() { + this.mounted = true; + this.listenHandle = this.props.model.addPedalboardItemEnabledChangeListener( + this.props.instanceId, + (instanceId: number, isEnabled: boolean) => { + let bypass = isEnabled ? 1.0: 0.0; + if (bypass !== this.value) { + this.setControlValue(bypass); + } + } + ); + this.requestUpdate(); + } + + onUnmount() { + if (this.listenHandle) { + this.props.model.removePedalboardItemEnabledChangeListener(this.listenHandle); + this.listenHandle = null; + } + this.mounted = false; + this.cancelRequestUpdate(); + } + + + render(): HTMLElement | null { + return null; + } + attach(frameElement: HTMLElement) { + + this.frameElement = frameElement; + this.requestUpdate(); + } +}; + + +interface FilmstripControlProps { + instanceId: number; + pluginControl: UiControl; + filmStrip: string; + verticalStrip: boolean; + + onValueChanged: (instanceId: number, symbol: string, value: number) => void; + monitorPort: ( + instanceId: number, + symbol: string, + interval: number, + callback: (value: number) => void) => MonitorPortHandle; + unmonitorPort: (handle: MonitorPortHandle) => void; +}; + +class FilmstripControl implements ModGuiControl { + + private props: FilmstripControlProps; + private frameElement?: HTMLElement; + + private value: number = -1.038327E-15 + private pointerDownValue: number = -1.038327E-15; + private isPointerDown: boolean = false; + private animationFrameId: number | null = null; + + constructor(props: FilmstripControlProps) { + this.props = props; + } + + + requestUpdate() { + if (!this.mounted) return; + if (!this.frameElement) { + return; + } + + + if (this.animationFrameId === null) { + this.animationFrameId = window.requestAnimationFrame(() => { + this.animationFrameId = null; + this.updateImagePosition(); + }); + } + } + cancelRequestUpdate() { + let animationFrameId = this.animationFrameId; + if (animationFrameId !== null) { + window.cancelAnimationFrame(animationFrameId); + this.animationFrameId = null; + } + } + setControlValue(value: number) { + if (this.value !== value) { + this.value = value; + this.requestUpdate(); + } + } + static urlRegex = /url\(["']?([^"')]+)["']?\)/i; + getImageUrl() { + if (!this.frameElement) { + throw new Error("Logic error: frameElement is not set."); + } + + let bgImage = getComputedStyle(this.frameElement).backgroundImage; + let urlMatch = bgImage.match(FilmstripControl.urlRegex); + if (urlMatch && urlMatch.length > 1) { + return urlMatch[1]; + } + return null; + } + + private currentImageUrl: string = ""; + + private updateImagePosition() { + if (!this.frameElement || !this.mounted) { + return; + } + + let rotationAttr = this.frameElement.getAttribute('mod-widget-rotation'); + if (rotationAttr && rotationAttr !== "") { + let rotation = parseFloat(rotationAttr); + if (isNaN(rotation)) { + console.error("pipedal: Invalid mod-widget-rotation value: " + rotationAttr); + return; + } + let range = this.valueToRange(this.value); + this.frameElement.style.transform = `rotate(${rotation * range - rotation / 2}deg)`; + return; + + } + let imageUrl = this.getImageUrl(); + if (!imageUrl) { + return; + } + if (imageUrl != this.currentImageUrl) { + this.filmstripInfo = null; + + this.currentImageUrl = imageUrl; + this.imgElement = new Image(); + let img = this.imgElement; + img.onload = () => { + if (!this.frameElement) { + return; + } + let computedStyle = window.getComputedStyle(this.frameElement); + let strWidth = computedStyle.getPropertyValue('width') || "0"; + let frameWidth = parseFloat(strWidth); + if (isNaN(frameWidth) || frameWidth <= 0) { frameWidth = 0; } + + let strHeight = computedStyle.getPropertyValue("height") || "0"; + let frameHeight = parseFloat(strHeight); + if (isNaN(frameHeight) || frameHeight <= 0) { frameHeight = 0; } + + let iHeight = 0; + let bgSize = computedStyle.getPropertyValue('background-size'); + if (bgSize) { + let bgSizeParts = bgSize.split(' '); + if (bgSizeParts.length === 2) { + iHeight = parseFloat(bgSizeParts[1]); + } + } + if (iHeight === 0) { + iHeight = frameHeight; + } + let imgFrameWidth = Math.round(frameWidth / iHeight * img.naturalHeight); + let nFrames = Math.round(img.naturalWidth / imgFrameWidth); + + this.filmstripInfo = { + width: img.naturalWidth, + height: img.naturalHeight, + frameWidth: frameWidth, + frameHeight: frameHeight, + nFrames: nFrames + }; + this.requestUpdate(); + }; + img.src = imageUrl; + return; + } + if (!this.filmstripInfo) { + return; // Not loaded yet + } + + + let xOffset = 0; + let yOffset = 0; + let pluginControl = this.props.pluginControl; + + let value = this.isPointerDown ? this.pointerDownValue : this.value; + value = this.clampValue(value); + + let isHorizontalStrip = + this.filmstripInfo.frameWidth / this.filmstripInfo.frameHeight + < this.filmstripInfo.width / this.filmstripInfo.height; + if (isHorizontalStrip) { + let range = this.valueToRange(value); + + if (range < 0) { + range = 0; + } + if (range > 1) { + range = 1; + } + let nFrame = Math.round(range * (this.filmstripInfo.nFrames - 1)); + xOffset = -nFrame * this.filmstripInfo.frameWidth; + yOffset = 0; + + } else { + xOffset = 0; + if (this.value <= (pluginControl.min_value + pluginControl.max_value) / 2) { + yOffset = 0; // Off position + } else { + yOffset = -this.frameElement.clientHeight; // On position + } + } + this.frameElement.style.backgroundPosition = `${xOffset}px ${yOffset}px`; + } + private mounted: boolean = false; + + private monitorHandle: MonitorPortHandle | null = null; + onMounted() { + this.mounted = true; + this.monitorHandle = this.props.monitorPort( + this.props.instanceId, + this.props.pluginControl.symbol, + 1.0 / 15, + (value: number) => { + if (value != this.value) { + this.setControlValue(value); + } + } + ); + this.requestUpdate(); + } + + onUnmount() { + if (this.monitorHandle) { + this.props.unmonitorPort(this.monitorHandle); + this.monitorHandle = null; + } + this.mounted = false; + this.cancelRequestUpdate(); + } + + private filmstripInfo: { + width: number, + height: number, + frameWidth: number, + frameHeight: number, + nFrames: number + } | null = null; + + private pointerIds: number[] = []; + private firstPointerId: number | null = null; + lastX: number = 0; + lastY: number = 0; + + handleToggleClick(event: PointerEvent) { + event.preventDefault(); + event.stopPropagation(); + this.firstPointerId = null; + this.isPointerDown = false; + if (!this.frameElement) { + return; // Not mounted yet + } + if (event.pointerType === "mouse" && event.button !== 0) { + return; // Only left mouse button + } + let newValue: number; + if (this.value === this.props.pluginControl.min_value) { + newValue = this.props.pluginControl.max_value; + } else { + newValue = this.props.pluginControl.min_value; + } + this.setControlValue(newValue); + this.props.onValueChanged(this.props.instanceId, this.props.pluginControl.symbol, newValue); + } + + handlePointerDown(event: PointerEvent) { + if (!this.frameElement) { + return; // Not mounted yet + } + if (event.pointerType === "mouse" && event.button !== 0) { + return; // Only left mouse button + } + if (this.props.pluginControl.controlType === ControlType.BypassLight) { + return; + } + this.frameElement.setPointerCapture(event.pointerId); + if (this.pointerIds.length === 0) { + this.firstPointerId = event.pointerId; + this.lastX = event.pageX; + this.lastY = event.pageY; + } + this.isPointerDown = true; + this.pointerIds.push(event.pointerId); + + if (this.props.pluginControl.controlType === ControlType.OnOffSwitch || + this.props.pluginControl.controlType === ControlType.ABSwitch) { + this.handleToggleClick(event); + return; + } + + event.preventDefault(); + event.stopPropagation(); + this.pointerDownValue = this.value; + } + + + valueToRange(value: number): number { + let uiControl = this.props.pluginControl; + if (uiControl) { + let range: number; + if (uiControl.is_logarithmic) { + let minValue = uiControl.min_value; + if (minValue === 0) // LSP plugins do this. + { + minValue = 0.0001; + } + range = Math.log(value / minValue) / Math.log(uiControl.max_value / minValue); + if (!isFinite(range)) { + if (range < 0) { + range = 0; + } else { + range = 1.0; + } + } else if (isNaN(range)) { + range = 0; + } + } else { + range = (value - uiControl.min_value) / (uiControl.max_value - uiControl.min_value); + if (!isFinite(range)) { + if (range < 0) { + range = 0; + } else { + range = 1.0; + } + } else if (isNaN(range)) { + range = 0; + } + } + + if (range > 1) range = 1; + if (range < 0) range = 0; + return range; + } + return 0; + } + rangeToValue(range: number): number { + if (range < 0) range = 0; + if (range > 1) range = 1; + let uiControl = this.props.pluginControl; + if (uiControl) { + let value: number; + if (uiControl.min_value === uiControl.max_value) { + value = uiControl.min_value; + } else { + if (uiControl.is_logarithmic) { + let minValue = uiControl.min_value; + if (minValue === 0) // LSP controls. + { + minValue = 0.0001; + } + value = minValue * Math.pow(uiControl.max_value / minValue, range); + if (!isFinite(value)) { + value = uiControl.max_value; + } else if (isNaN(value)) { + value = uiControl.min_value; + } + } else { + value = range * (uiControl.max_value - uiControl.min_value) + uiControl.min_value; + } + } + return value; + } + return 0; + } + + clampValue(value: number): number { + let control = this.props.pluginControl; + if (control.enumeration_property) { + value = control.clampSelectValue(value); + } else if (control.toggled_property) { + if (value < (control.min_value + control.max_value) / 2) { + value = control.min_value; + } else { + value = control.max_value; + } + } else if (control.integer_property) { + value = Math.round(value); + } else if (control.range_steps != 0) { + let step = (control.max_value - control.min_value) / control.range_steps; + value = Math.round((value - control.min_value) / step) * step + control.min_value; + } + if (value < control.min_value) { + value = control.min_value; + } + if (value > control.max_value) { + value = control.max_value; + } + return value; + } + + handlePointerMove(event: PointerEvent) { + if (!this.frameElement) { + return; // Not mounted yet + } + + if (this.firstPointerId === null || !this.frameElement.hasPointerCapture(event.pointerId)) { + return; // Not the first pointer or not captured + } + event.preventDefault(); + event.stopPropagation(); + let dy = event.pageY - this.lastY; + this.lastY = event.pageY; + this.lastX = event.pageX; + + let rate = RANGE_SCALE; // pixels for full range. + if (this.pointerIds.length == 2) { + rate = FINE_RANGE_SCALE; + } else if (this.pointerIds.length > 2) { + rate = ULTRA_FINE_RANGE_SCALE; + } else if (event.ctrlKey) { + rate = ULTRA_FINE_RANGE_SCALE; + } else if (event.shiftKey) { + rate = FINE_RANGE_SCALE; + } + + let dRange = -dy / rate; + + let range = this.valueToRange(this.pointerDownValue); + range += dRange; + if (range < 0) range = 0; + if (range > 1) range = 1; + let newValue = this.rangeToValue(range); + this.pointerDownValue = newValue; + this.setControlValue(newValue); + this.props.onValueChanged(this.props.instanceId, this.props.pluginControl.symbol, this.clampValue(newValue)); + } + handlePointerUp(event: PointerEvent) { + let index = this.pointerIds.indexOf(event.pointerId); + if (index >= 0) { + this.pointerIds.splice(index, 1); + } else { + return; + } + this.isPointerDown = false; + this.requestUpdate(); + if (event.pointerId === this.firstPointerId) { + this.firstPointerId = null; + } + if (!this.frameElement) { + return; // Not mounted yet + } + + this.frameElement.releasePointerCapture(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + } + handlePointerCancel(event: PointerEvent) { + if (!this.frameElement) { + return; // Not mounted yet + } + let index = this.pointerIds.indexOf(event.pointerId); + if (index >= 0) { + this.pointerIds.splice(index, 1); + } + if (event.pointerId === this.firstPointerId) { + this.firstPointerId = null; + } + if (this.pointerIds.length === 0) { + this.firstPointerId = null; + this.lastY = 0; + this.isPointerDown = false; + this.requestUpdate(); + + } + } + handleMouseWheel(event: WheelEvent) { + if (!this.frameElement) { + return; // Not mounted yet + } + event.preventDefault(); + event.stopPropagation(); + let rate = WHEEL_RANGE_SCALE; // pixels for full range. + if (event.ctrlKey) { + rate = ULTRA_FINE_WHEEL_RANGE_SCALE; + } else if (event.shiftKey) { + rate = FINE_WHEEL_RANGE_SCALE; + } + let dRange = event.deltaY / rate; + + let range = this.valueToRange(this.value); + range += dRange; + if (range < 0) range = 0; + if (range > 1) range = 1; + let newValue = this.rangeToValue(range); + this.setControlValue(newValue); + this.props.onValueChanged(this.props.instanceId, this.props.pluginControl.symbol, newValue); + } + + + private imgElement: HTMLImageElement | null = null; + render(): HTMLElement | null { + return null; + } + attach(frameElement: HTMLElement) { + + this.frameElement = frameElement; + this.frameElement.onpointerdown = (event: PointerEvent) => { + this.handlePointerDown(event); + }; + this.frameElement.onpointerup = (event: PointerEvent) => { + if (!this.frameElement) { + return; // Not mounted yet + } + this.handlePointerUp(event); + }; + this.frameElement.onpointermove = (event: PointerEvent) => { + + if (!this.frameElement) { + return; // Not mounted yet + } + if (this.frameElement.hasPointerCapture(event.pointerId)) { + this.handlePointerMove(event); + return; + } + } + this.frameElement.onpointercancel = (event: PointerEvent) => { + this.handlePointerCancel(event); + } + this.frameElement.onwheel = (event: WheelEvent) => { + this.handleMouseWheel(event); + }; + this.requestUpdate(); + } +}; + + +function ModGuiHost(props: ModGuiHostProps) { + let model: PiPedalModel = PiPedalModelFactory.getInstance(); + const { plugin, onClose } = props; + const [hostDivRef, setHostDivRef] = React.useState(null); + const [errorMessage, setErrorMessage] = React.useState(null); + const [contentReady, setContentReady] = React.useState(null); + const [modGuiControls] = React.useState([]); + const [ready, setReady] = React.useState(model.state.get() === State.Ready); + const [maximizedUi] = React.useState(false); + + + function updateContentReady(value: boolean) { + if (value !== contentReady) { + setContentReady(value); + props.onContentReady(value); + } + } + + if (!plugin.modGui) { + return ( +
+ No Mod GUI +
+ ); + } + + + function addPortClass(element: Element, selector: string, className: string) { + let children = element.querySelectorAll(selector); + // call addClass to each element that matches the selector + children.forEach((child) => { + child.classList.add(className); + }); + } + + function createCustomSelectControl(control: Element, symbol: string, pluginControl: UiControl) { + let customSelectControl = new CustomSelectControl( + { + instanceId: props.instanceId, + pluginControl: pluginControl, + onValueChanged: (instanceId: number, symbol: string, value: number) => { + try { + model.setPedalboardControl(instanceId, symbol, value); + } catch (error) { + + } + }, + monitorPort: model.monitorPort.bind(model), + unmonitorPort: model.unmonitorPort.bind(model) + } + ); + customSelectControl.attach(control as HTMLElement); + modGuiControls.push(customSelectControl); + let modGuiElement = customSelectControl.render(); + if (modGuiElement !== null) { + control.appendChild(modGuiElement); + } + customSelectControl.onMounted(); + } + function monitorBypassPort( + instanceId: number, + symbol: string, + interval: number, + callback: (value: number) => void): MonitorPortHandle { + return model.addPedalboardItemEnabledChangeListener( + instanceId, (instanceId,value) => { + callback(value ? 1 : 0); + } + ); + } + function unmonitorBypassPort(handle: MonitorPortHandle) { + model.removePedalboardItemEnabledChangeListener(handle as ListenHandle); + } + function createFootswitchControl(control: Element, symbol: string, controlType: ControlType) { + let uiControl = new UiControl(); + uiControl.symbol = symbol; + uiControl.controlType = controlType; + uiControl.min_value = 0; + uiControl.max_value = 1; + uiControl.is_logarithmic = false; + uiControl.integer_property = true; + + let filmstripControl = new FilmstripControl( + { + instanceId: props.instanceId, + pluginControl: uiControl, + filmStrip: "/img/footswitch_strip.png", + verticalStrip: true, + onValueChanged: (instanceId: number, symbol: string, value: number) => { + if (symbol === "_bypass") { + model.setPedalboardItemEnabled(instanceId, value !== 0); + } else { + try { + model.setPedalboardControl(instanceId, symbol, value); + } catch (error) { + + } + } + }, + monitorPort: + symbol === "_bypass" ? + monitorBypassPort : + model.monitorPort.bind(model), + unmonitorPort: + symbol == "_bypass" ? + unmonitorBypassPort : + model.unmonitorPort.bind(model) + } + ); + filmstripControl.attach(control as HTMLElement); + modGuiControls.push(filmstripControl); + let modGuiElement = filmstripControl.render(); + if (modGuiElement !== null) { + control.appendChild(modGuiElement); + } + filmstripControl.onMounted(); + } + function createCustomSelectPathControl(control: Element) { + let pathUri = control.getAttribute("mod-parameter-uri"); + if (!pathUri) { + setModError("No mod-parameter-uri attribute found on control element."); + return; + } + let selectPathControl = new CustomSelectPathControl({ + instanceId: props.instanceId, + propertyUri: pathUri, + plugin: props.plugin, + hostSite: model, + handleFileSelect: props.handleFileSelect + }); + + selectPathControl.attach(control as HTMLElement); + modGuiControls.push(selectPathControl); + selectPathControl.onMounted(); + } + + + function createBypassLightControl(control: Element) { + + let bypassLightControl = new BypassLightControl( + { + instanceId: props.instanceId, + model: model + } + ); + bypassLightControl.attach(control as HTMLElement); + modGuiControls.push(bypassLightControl); + let modGuiElement = bypassLightControl.render(); + if (modGuiElement !== null) { + control.appendChild(modGuiElement); + } + bypassLightControl.onMounted(); + } + function createDialControl(control: Element, pluginControl: UiControl) { + let modGui = plugin.modGui; + if (!modGui) { + alert("Logic error"); + return; + } + let knobUrl: string = "/img/BlackKnob.png"; + if (modGui.knob !== "") { + knobUrl = modGui.knob; + } + let modGuiControl = new FilmstripControl( + { + instanceId: props.instanceId, + pluginControl: pluginControl, + filmStrip: knobUrl, + verticalStrip: false, + onValueChanged: (instanceId: number, symbol: string, value: number) => { + try { + model.setPedalboardControl(instanceId, symbol, value); + } catch (error) { + + } + }, + monitorPort: model.monitorPort.bind(model), + unmonitorPort: model.unmonitorPort.bind(model) + + } + ); + modGuiControls.push(modGuiControl); + modGuiControl.attach(control as HTMLElement); + + let modGuiElement = modGuiControl.render(); + if (modGuiElement !== null) { + control.appendChild(modGuiElement); + } + modGuiControl.onMounted(); + + } + function prepareElement(element: Element) { + addPortClass(element, '[mod-role="input-audio-port"]', "mod-audio-input"); + addPortClass(element, '[mod-role="output-audio-port"]', "mod-audio-output"); + // addPortClass(element,'[mod-role="input-midi-port"]', "mod-midi-input"); + // addPortClass(element,'[mod-role="output-midi-port"]', "mod-midi-output"); + // addPortClass(element,'[mod-role="input-cv-port"]', "mod-cv-input"); + // addPortClass(element,'[mod-role="output-cv-port"]', "mod-cv-output"); + + + element.querySelectorAll('[mod-role=input-control-port]') + .forEach((control) => { + let symbol = control.getAttribute("mod-port-symbol"); + + if (symbol) { + let pluginControl: UiControl | undefined = plugin.getControl(symbol); + if (!pluginControl) { + setModError(`No plugin info found for symbol ${symbol}`); + return; + } + let widgetType = control.getAttribute("mod-widget") || ""; + // 'film': 'film', + // 'switch': 'switchWidget', + // 'bypass': 'bypassWidget', + // 'select': 'selectWidget', + // 'string': 'stringWidget', + // 'custom-select': 'customSelect', + // 'custom-select-path': 'customSelectPath', + + switch (widgetType) { + case "": + case "film": + createDialControl(control, pluginControl); + break; + case "switch": + createFootswitchControl(control, symbol, ControlType.OnOffSwitch); + break; + case "custom-select": + createCustomSelectControl(control, symbol, pluginControl); + break; + case "custom-select-path": + case "bypass": + case "string": + setModError("Unsupported widget type: " + widgetType); + break; + } + } else { + setModError("No mod-symbol attribute found on control element."); + } + }); + element.querySelectorAll('[mod-role=bypass]') + .forEach((control) => { + let symbol = "_bypass"; + let controlType = ControlType.OnOffSwitch; + createFootswitchControl(control, symbol, controlType); + }); + element.querySelectorAll('[mod-role=bypass-light]') + .forEach((control) => { + createBypassLightControl(control); + }); + element.querySelectorAll('[mod-role=input-parameter]') + .forEach((control) => { + // stub: mod-widget="custom-select-path". Are there other widgets for this? + createCustomSelectPathControl(control); + }); + } + function setModError(message: string) { + setErrorMessage(ModMessage(message)); + } + async function requestContent() { + updateContentReady(false); + try { + let modGui = plugin.modGui; + if (!modGui) { + setModError("No MOD GUI declared for this plugin."); + return; + } + if (!modGui.iconTemplate) { + setModError("Template file missing."); + return; + } + if (!modGui.stylesheet) { + setModError("Stylesheet file missing."); + return; + } + + let encodedUri = encodeURIComponent(props.plugin.uri); + let version = props.plugin.minorVersion * 1000 + props.plugin.microVersion; + + let resourceUrl = model.modResourcesUrl; + let queryParams = "?ns=" + encodedUri + "&v=" + version.toString(); + let templateUri = resourceUrl + "_/iconTemplate" + queryParams; + let cssUri = resourceUrl + "_/stylesheet" + queryParams; + + let fetchResult = await fetch(templateUri); + if (!fetchResult.ok) { + setModError("Failed to load template: " + fetchResult.statusText); + return; + } + let parser = new DOMParser(); + let docText = await fetchResult.text(); + let doc = parser.parseFromString(docText, "text/html"); + let element = doc.body.firstElementChild; + if (!element) { + setModError("No root element found in template."); + return; + } + let cssResult = await fetch(cssUri); + if (!cssResult.ok) { + setModError("Failed to load stylesheet: " + cssResult.statusText); + return; + } + if (hostDivRef === null) { + return; // cancelled. + } + + let t = await cssResult.text(); + let cssText = ""; + let cssDoc = parser.parseFromString(cssText, "text/html"); + let cssElement = cssDoc.getElementById('mod-gui-style'); + if (!cssElement) { + setModError("No style element found."); + return; + } + if (hostDivRef === null) { + return; // cancelled. + } + prepareElement(element); + // add element to the hostDivRef + hostDivRef.appendChild(cssElement); + hostDivRef.appendChild(element); + let width = element.clientWidth; + let height = element.clientHeight; + hostDivRef.style.width = width + "px"; + hostDivRef.style.height = height + "px"; + updateContentReady(true); + } catch (error) { + setModError("Error loading UI: " + (error instanceof Error ? error.message : String(error))); + } + + } + + React.useEffect(() => { + let tHostDivRef = hostDivRef; + if (ready) { + if (hostDivRef !== null) { + requestContent(); + } + } + let stateHandler = (state: State) => { + setReady(state === State.Ready); + } + model.state.addOnChangedHandler(stateHandler) + + + return () => { + model.state.removeOnChangedHandler(stateHandler); + + if (tHostDivRef !== null) { + // unmount the custom content. + let children = tHostDivRef.children; + for (let i = children.length - 1; i >= 0; i--) { + let child = children[i]; + if (child instanceof HTMLElement) { + child.remove(); + } + } + } + updateContentReady(false); + let mc = modGuiControls; + for (let i = 0; i < mc.length; i++) { + let modGuiControl = mc[i]; + modGuiControl.onUnmount(); + } + mc.length = 0; // Clear the controls array + }; + }, [hostDivRef, plugin, ready]); + + + return ( + { props.onClose(); setErrorMessage(null); }}> +
{ + // Prevent click events from propagating to the parent element. + event.stopPropagation(); + }} + > + {maximizedUi && ( + onClose()} style={{ + position: "absolute", top: 16, right: 16, + background: "#FFF5", zIndex: 600 + }}> + + + )} + +
+
{ + // Prevent click events from propagating to the parent element. + event.stopPropagation(); + } + } + /> +
+ +
+ {errorMessage !== null && ( + { + setErrorMessage(null); + onClose(); + }} + text={errorMessage ?? ""} + /> + )} + + + ); +} + +interface ModGuiPluginPreference { + pluginUri: string; + useModGui: boolean; +} + +let modGuiPluginPreferences: ModGuiPluginPreference[] | null = null; + + +function loadModGuiPreferences() { + if (modGuiPluginPreferences === null) { + let prefs = window.localStorage.getItem("modGuiPluginPreferences"); + try { + if (prefs) { + modGuiPluginPreferences = JSON.parse(prefs); + } else { + modGuiPluginPreferences = []; + } + } catch (error) { + console.error("pipedal: Error parsing modGuiPluginPreferences from localStorage: " + String(error)); + modGuiPluginPreferences = []; + } + } +} + +export function getDefaultModGuiPreference(pluginUri: string) { + loadModGuiPreferences(); + if (modGuiPluginPreferences === null) { + modGuiPluginPreferences = []; + } + if (modGuiPluginPreferences) { + let preference = modGuiPluginPreferences.find(p => p.pluginUri === pluginUri); + if (preference) { + return preference.useModGui; + } + } + return false; +} + + +export function setDefaultModGuiPreference(pluginUri: string, useModGui: boolean) { + loadModGuiPreferences(); + if (modGuiPluginPreferences === null) { + modGuiPluginPreferences = []; + } + + let preference = modGuiPluginPreferences.find(p => p.pluginUri === pluginUri); + if (preference) { + preference.useModGui = useModGui; + } else { + modGuiPluginPreferences.push({ pluginUri: pluginUri, useModGui }); + } + try { + window.localStorage.setItem("modGuiPluginPreferences", JSON.stringify(modGuiPluginPreferences)); + } catch(error) { + console.error("pipedal: Error saving modGuiPluginPreferences to localStorage: " + String(error)); + } +} + + +export default ModGuiHost; \ No newline at end of file diff --git a/vite/src/pipedal/OkDialog.tsx b/vite/src/pipedal/OkDialog.tsx new file mode 100644 index 0000000..cc0d4cc --- /dev/null +++ b/vite/src/pipedal/OkDialog.tsx @@ -0,0 +1,105 @@ +/* + * MIT License + * + * Copyright (c) 2022 Robin E. R. Davies + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React from 'react'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import IconButtonEx from './IconButtonEx'; +import Button from '@mui/material/Button'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import DialogEx from './DialogEx'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; + +import ReportGmailerrorredIcon from '@mui/icons-material/ReportGmailerrorred'; + +export interface OkDialogProps { + open: boolean, + title?: string, + text: string, + okButtonText?: string, + onClose: () => void +}; + +export interface OkDialogState { +}; + +export default class OkDialog extends React.Component { + + + constructor(props: OkDialogProps) { + super(props); + this.state = { + }; + } + + render() { + let props = this.props; + let { open, okButtonText, text, onClose } = props; + + const handleClose = () => { + onClose(); + }; + + const handleOk = () => { + onClose(); + } + return ( + + {props.title && ( + + + {this.props.onClose();}} aria-label="back" + > + + + + {props.title} + + + )} + + +
+ + {text} +
+
+ + + + +
+ ); + } +} \ No newline at end of file diff --git a/vite/src/pipedal/Pedalboard.tsx b/vite/src/pipedal/Pedalboard.tsx index c18a2cc..34c54a3 100644 --- a/vite/src/pipedal/Pedalboard.tsx +++ b/vite/src/pipedal/Pedalboard.tsx @@ -79,6 +79,8 @@ export class PedalboardItem implements Deserializable { this.lv2State = input.lv2State; this.lilvPresetUri = input.lilvPresetUri; this.pathProperties = input.pathProperties; + this.useModUi = input.useModUi ?? false; + return this; } deserialize(input: any): PedalboardItem { @@ -212,6 +214,7 @@ export class PedalboardItem implements Deserializable { lv2State: [boolean,any] = [false,{}]; lilvPresetUri: string = ""; pathProperties: {[Name: string]: string} = {}; + useModUi: boolean = false; // true if this item should use the mod-ui. }; export class SnapshotValue { @@ -369,6 +372,7 @@ export class Pedalboard implements Deserializable { this.snapshots = input.snapshots ? Snapshot.deserializeArray(input.snapshots): []; this.selectedSnapshot = input.selectedSnapshot; this.pathProperties = input.pathProperties; + this.selectedPlugin = input.selectedPlugin??-1; return this; } @@ -384,6 +388,7 @@ export class Pedalboard implements Deserializable { snapshots: (Snapshot | null)[] = []; selectedSnapshot: number = -1; pathProperties: {[Name: string]: string} = {}; + selectedPlugin: number = -1; *itemsGenerator(): Generator { let it = itemGenerator_(this.items); diff --git a/vite/src/pipedal/PedalboardView.tsx b/vite/src/pipedal/PedalboardView.tsx index ffd73f1..7b3bfcd 100644 --- a/vite/src/pipedal/PedalboardView.tsx +++ b/vite/src/pipedal/PedalboardView.tsx @@ -43,6 +43,7 @@ import { } from './Pedalboard'; import MidiIcon from './svg/ic_midi.svg?react'; +import { midiChannelBindingControlFeatureEnabled } from './MidiChannelBinding'; const START_CONTROL = Pedalboard.START_CONTROL; @@ -540,7 +541,9 @@ withStyles( } onPedalboardChanged(value?: Pedalboard) { - this.setState({ pedalboard: value }); + this.setState({ + pedalboard: value, + }); } componentDidMount() { @@ -993,7 +996,7 @@ withStyles( const classes = withStyles.getClasses(this.props); return (
{ e.preventDefault(); }}> - {hasMidiConnector && ( + {hasMidiConnector && midiChannelBindingControlFeatureEnabled && (
diff --git a/vite/src/pipedal/PerformanceView.tsx b/vite/src/pipedal/PerformanceView.tsx index db346c1..c05142b 100644 --- a/vite/src/pipedal/PerformanceView.tsx +++ b/vite/src/pipedal/PerformanceView.tsx @@ -79,6 +79,7 @@ interface PerformanceViewState { presets: PresetIndex; banks: BankIndex; showSnapshotEditor: boolean; + showStatusMonitor: boolean; snapshotEditorIndex: number; presetModified: boolean; } @@ -100,15 +101,24 @@ export const PerformanceView = banks: this.model.banks.get(), wrapSelects: false, showSnapshotEditor: false, + showStatusMonitor: this.model.showStatusMonitor.get(), snapshotEditorIndex: 0, presetModified: this.model.presetChanged.get() }; this.onPresetsChanged = this.onPresetsChanged.bind(this); this.onBanksChanged = this.onBanksChanged.bind(this); this.onPresetChangedChanged = this.onPresetChangedChanged.bind(this); + this.showStatusMonitorHandler = this.showStatusMonitorHandler.bind(this); } + + showStatusMonitorHandler() { + this.setState({ + showStatusMonitor: this.model.showStatusMonitor.get() + }); + } + getTag() { return "performView"} isOpen() { return this.props.open } onDialogStackClose() { @@ -156,6 +166,8 @@ export const PerformanceView = this.model.presets.addOnChangedHandler(this.onPresetsChanged); this.model.banks.addOnChangedHandler(this.onBanksChanged); this.model.presetChanged.addOnChangedHandler(this.onPresetChangedChanged) + this.model.showStatusMonitor.addOnChangedHandler(this.showStatusMonitorHandler); + this.setState({ presets: this.model.presets.get(), banks: this.model.banks.get(), @@ -173,6 +185,8 @@ export const PerformanceView = this.model.presetChanged.removeOnChangedHandler(this.onPresetChangedChanged) this.model.presets.removeOnChangedHandler(this.onPresetsChanged); this.model.banks.removeOnChangedHandler(this.onBanksChanged); + this.model.banks.removeOnChangedHandler(this.showStatusMonitorHandler); + this.mounted = false; this.updateHooks(); @@ -393,10 +407,10 @@ export const PerformanceView = { return this.handleOnEdit(index); }} />
- - {!this.state.showSnapshotEditor && ( - ) - } + + {!this.state.showSnapshotEditor && this.state.showStatusMonitor && ( + + )}
{this.state.showSnapshotEditor && ( = 0) { + this.hostName = hostVersion.substring(0, pos).trim(); + remainder = hostVersion.substring(pos + 1).trim(); + } else { + this.hostName = "Unknown Host"; + remainder = "v0.0.0"; + } + + const args = remainder.split(","); // make make provisions for more. + if (args.length >= 1) + { + let versionString = args[0].trim(); + if (versionString.startsWith('v')) { + versionString = versionString.substring(1).trim(); + } + let releaseTypePos = versionString.indexOf("-"); + if (releaseTypePos >= 0) { + this.releaseType = versionString.substring(releaseTypePos + 1).trim(); + versionString = versionString.substring(0, releaseTypePos).trim(); + } + else { + this.releaseType = "Release"; + } + + const parts = args[0].split("."); + if (parts.length >= 3) { + this.majorVersion = parseInt(parts[0]); + this.minorVersion = parseInt(parts[1]); + this.buildNumber = parseInt(parts[2]); + } + } + } + lessThan(major: number, minor: number, build: number): boolean { + if (this.majorVersion < major) return true; + if (this.majorVersion > major) return false; + if (this.minorVersion < minor) return true; + if (this.minorVersion > minor) return false; + return this.buildNumber < build; + } + versionString: string = ""; + hostName: string = ""; + releaseType: string = ""; + majorVersion: number = 0; + minorVersion: number = 0; + buildNumber: number = 0; +} +export type PedalboardItemEnabledChangeCallback = (instanceId: number, isEnabled: boolean) => void; +interface PedalboardItemEnabledChangeItem { + handle: number; + instanceId: number; + currentValue: boolean; + onEnabledChanged: PedalboardItemEnabledChangeCallback; +}; + +export type PedalboardItemUseModUiChangeCallback = (instanceId: number, useModUi: boolean) => void; + +interface PedalboardItemUseModUiChangeItem { + handle: number; + instanceId: number; + currentValue: boolean; + onUseModUiChanged: PedalboardItemUseModUiChangeCallback; +}; + + export type ControlValueChangedHandler = (key: string, value: number) => void; export interface FileEntry { @@ -146,7 +218,6 @@ export interface VuUpdateInfo { }; export interface MonitorPortHandle { - }; export interface ControlValueChangedHandle { _ControlValueChangedHandle: number; @@ -416,12 +487,19 @@ export class PiPedalModel //implements PiPedalModel socketServerUrl: string = ""; varServerUrl: string = ""; + modResourcesUrl: string = ""; lv2Path: string = ""; webSocket?: PiPedalSocket; hasTone3000Auth: ObservableProperty = new ObservableProperty(false); + canKeepScreenOn: boolean = false; + keepScreenOn: ObservableProperty = new ObservableProperty(false); + + canSetScreenOrientation: boolean = false; + screenOrientation: ObservableProperty = new ObservableProperty(ScreenOrientation.SystemDefault); + hasWifiDevice: ObservableProperty = new ObservableProperty(false); onSnapshotModified: ObservableEvent = new ObservableEvent(); @@ -486,6 +564,7 @@ export class PiPedalModel //implements PiPedalModel } androidHost?: AndroidHostInterface; + hostVersion?: HostVersion; constructor() { this.androidHost = (window as any).AndroidHost as AndroidHostInterface; @@ -573,16 +652,31 @@ export class PiPedalModel //implements PiPedalModel return true; } + private updateEnabledItems(pedalboard: Pedalboard) + { + for (let item of pedalboard.itemsGenerator()) { + this.updatePedalboardItemEnabled(item.instanceId, item.isEnabled); + this.updatePedalboardItemUseModUi(item.instanceId, item.useModUi); + } + } + private setModelPedalboard(pedalboard: Pedalboard) { this.pedalboard.set(pedalboard); this.selectedSnapshot.set(pedalboard.selectedSnapshot); - + this.updateEnabledItems(pedalboard); } onSocketMessage(header: PiPedalMessageHeader, body?: any) { let message = header.message; if (message === "onControlChanged") { let controlChangedBody = body as ControlChangedBody; + if (body.clientId !== this.clientId) { + this.lastControlMessageWasSentbyMe = false; + } + if (this.lastControlMessageWasSentbyMe) { + return; // shortcut! + } + this._setPedalboardControlValue( controlChangedBody.instanceId, controlChangedBody.symbol, @@ -686,6 +780,13 @@ export class PiPedalModel //implements PiPedalModel itemEnabledBody.enabled, false // No server notification. ); + } else if (message == "onItemUseModUiChanged") { + let itemEnabledBody = body as PedalboardItemEnableBody; + this._setPedalboardItemUseModUi( + itemEnabledBody.instanceId, + itemEnabledBody.enabled, + false // No server notification. + ); } else if (message === "onPedalboardChanged") { let pedalChangedBody = body as PedalboardChangedBody; this.setModelPedalboard(new Pedalboard().deserialize(pedalChangedBody.pedalboard)); @@ -972,13 +1073,17 @@ export class PiPedalModel //implements PiPedalModel // anything could have changed while we were disconnected. await this.loadServerState(); } - makeSocketServerUrl(hostName: string, port: number): string { + private makeSocketServerUrl(hostName: string, port: number): string { return "ws://" + hostName + ":" + port + "/pipedal"; } - makeVarServerUrl(protocol: string, hostName: string, port: number): string { + private makeVarServerUrl(protocol: string, hostName: string, port: number): string { return protocol + "://" + hostName + ":" + port + "/var/"; + } + private makeModResourceUrl(protocol: string, hostName: string, port: number): string { + return protocol + "://" + hostName + ":" + port + "/resources/"; + } async getNextAudioFile(filePath: string): Promise { @@ -1053,6 +1158,7 @@ export class PiPedalModel //implements PiPedalModel if (!socket_server_port) socket_server_port = 8080; let socket_server = this.makeSocketServerUrl(socket_server_address, socket_server_port); let var_server_url = this.makeVarServerUrl("http", socket_server_address, socket_server_port); + this.modResourcesUrl = this.makeModResourceUrl("http", socket_server_address, socket_server_port); this.socketServerUrl = socket_server; this.varServerUrl = var_server_url; @@ -1250,6 +1356,17 @@ export class PiPedalModel //implements PiPedalModel .then((succeeded) => { if (succeeded) { this.state.set(State.Ready); + if (this.androidHost) { + this.hostVersion = new HostVersion(this.androidHost.getHostVersion()); + if (!this.hostVersion.lessThan(1,1,16)) + { + this.androidHost.setServerVersion(this.serverVersion?.serverVersion??""); + this.canKeepScreenOn = true; + this.keepScreenOn.set(this.androidHost.getKeepScreenOn()) + this.canSetScreenOrientation = true; + this.screenOrientation.set(this.androidHost.getScreenOrientation()) + } + } } }) .catch((error) => { @@ -1491,6 +1608,7 @@ export class PiPedalModel //implements PiPedalModel } } + private lastControlMessageWasSentbyMe = false; private _setPedalboardControlValue(instanceId: number, key: string, value: number, notifyServer: boolean): void { let pedalboard = this.pedalboard.get(); @@ -1509,6 +1627,7 @@ export class PiPedalModel //implements PiPedalModel if (changed) { if (notifyServer) { + this.lastControlMessageWasSentbyMe = true; this._setServerControl("setControl", instanceId, key, value); } this.setModelPedalboard(newPedalboard); @@ -1557,6 +1676,42 @@ export class PiPedalModel //implements PiPedalModel setPedalboardItemEnabled(instanceId: number, value: boolean): void { this._setPedalboardItemEnabled(instanceId, value, true); } + + setPedalboardItemUseModUi(instanceId: number, useModUi: boolean): void { + this._setPedalboardItemUseModUi(instanceId, useModUi, true); + } + + getPedalboardItemUseModUi(instanceId: number): boolean { + if (!this.pedalboard.get().hasItem(instanceId)) return false; + let item = this.pedalboard.get().getItem(instanceId); + return item.useModUi; + } + _setPedalboardItemUseModUi(instanceId: number, useModUi: boolean, notifyServer: boolean): void { + let pedalboard = this.pedalboard.get(); + if (pedalboard === undefined) throw new PiPedalStateError("Pedalboard not ready."); + let newPedalboard = pedalboard.clone(); + let item = newPedalboard.getItem(instanceId); + let changed = useModUi !== item.useModUi; + if (changed) { + item.useModUi = useModUi; + this.setModelPedalboard(newPedalboard); + if (notifyServer) { + let body = { + clientId: this.clientId, + instanceId: instanceId, + useModUi: useModUi + }; + this.webSocket?.send("setPedalboardItemUseModUi", body); + } + } + } + + + getPedalboardItemEnabled(instanceId: number): boolean { + if (!this.pedalboard.get().hasItem(instanceId)) return false; + let item = this.pedalboard.get().getItem(instanceId); + return item.isEnabled; + } private _setPedalboardItemEnabled(instanceId: number, value: boolean, notifyServer: boolean): void { let pedalboard = this.pedalboard.get(); if (pedalboard === undefined) throw new PiPedalStateError("Pedalboard not ready."); @@ -1619,6 +1774,7 @@ export class PiPedalModel //implements PiPedalModel item.lv2State = [false, {}]; item.vstState = ""; item.pathProperties = {}; + item.useModUi = getDefaultModGuiPreference(selectedUri); for (let fileProperty of plugin.fileProperties) { // stringized json for an atom. see AtomConverter.hpp. // null -> we've never seen a value. @@ -1664,6 +1820,7 @@ export class PiPedalModel //implements PiPedalModel let result = newPedalboard.deleteItem(instanceId); if (result !== null) { + newPedalboard.selectedPlugin = result; this.setModelPedalboard(newPedalboard); this.updateServerPedalboard(); } @@ -1839,6 +1996,7 @@ export class PiPedalModel //implements PiPedalModel } newPedalboard.setItemEmpty(item); + this.setModelPedalboard(newPedalboard); this.updateServerPedalboard(); return item.instanceId; @@ -2204,6 +2362,18 @@ export class PiPedalModel //implements PiPedalModel } getPatchProperty(instanceId: number, uri: string): Promise { let result = new Promise((resolve, reject) => { + let pedalboard = this.pedalboard.get(); + if (pedalboard) { + let item = pedalboard.getItem(instanceId); + if (item) { + if (item.pathProperties.hasOwnProperty(uri)) { + let value = item.pathProperties[uri]; + let jsonValue = JSON.parse(value); + resolve(jsonValue as Type); + } + + } + } if (!this.webSocket) { reject("Socket closed."); } else { @@ -3227,6 +3397,129 @@ export class PiPedalModel //implements PiPedalModel throw new Error("No connection."); } + + private pedalboardItemEnabledChangeListeners: PedalboardItemEnabledChangeItem[] = []; + private pedalboardItemUseModUiChangeListeners: PedalboardItemUseModUiChangeItem[] = []; + + private updatePedalboardItemEnabled(instanceId: number, enabled: boolean) { + for (let listener of this.pedalboardItemEnabledChangeListeners) { + if (listener.instanceId === instanceId) { + if (listener.currentValue !== enabled) { + listener.currentValue = enabled; + listener.onEnabledChanged(instanceId, enabled); + } + } + } + } + + private updatePedalboardItemUseModUi(instanceId: number, useModUi: boolean) { + for (let listener of this.pedalboardItemUseModUiChangeListeners) { + if (listener.instanceId === instanceId) { + if (listener.currentValue !== useModUi) { + listener.currentValue = useModUi; + listener.onUseModUiChanged(instanceId, useModUi); + } + } + } + } + + addPedalboardItemEnabledChangeListener( + instanceId: number, + onEnabledChanged: PedalboardItemEnabledChangeCallback + ) : ListenHandle{ + let handle = ++this.nextListenHandle; + let currentValue = this.getPedalboardItemEnabled(instanceId); + + let entry: PedalboardItemEnabledChangeItem = { + instanceId: instanceId, + handle: handle, + currentValue: currentValue, + onEnabledChanged: onEnabledChanged + }; + + this.pedalboardItemEnabledChangeListeners.push( entry); + + onEnabledChanged(instanceId, currentValue); + + return { _handle: handle }; + } + addPedalboardItemUseModUiChangeListener( + instanceId: number, + onUseModUiChanged : PedalboardItemUseModUiChangeCallback + ) : ListenHandle{ + + let handle = ++this.nextListenHandle; + let currentValue = this.getPedalboardItemUseModUi(instanceId); + + let entry: PedalboardItemUseModUiChangeItem = { + instanceId: instanceId, + handle: handle, + currentValue: currentValue, + onUseModUiChanged: onUseModUiChanged + }; + + this.pedalboardItemUseModUiChangeListeners.push( entry); + + onUseModUiChanged(instanceId, currentValue); + + return { _handle: handle }; + } + + removePedalboardItemEnabledChangeListener(listenHandle: ListenHandle): boolean { + for (let i = 0; i < this.pedalboardItemEnabledChangeListeners.length; ++i) { + if (this.pedalboardItemEnabledChangeListeners[i].handle === listenHandle._handle) { + this.pedalboardItemEnabledChangeListeners.splice(i, 1); + return true; + } + } + return false; + } + + removePedalboardItemUseModUiChangeListener(listenHandle: ListenHandle): boolean { + for (let i = 0; i < this.pedalboardItemUseModUiChangeListeners.length; ++i) { + if (this.pedalboardItemUseModUiChangeListeners[i].handle === listenHandle._handle) { + this.pedalboardItemUseModUiChangeListeners.splice(i, 1); + return true; + } + } + return false; + } + + setKeepScreenOn(keepScreenOn: boolean): void { + if (this.canKeepScreenOn) { + this.keepScreenOn.set(keepScreenOn); + if (this.androidHost) { + this.androidHost.setKeepScreenOn(keepScreenOn); + } + } + } + getKeepScreenOn(): boolean { + return this.keepScreenOn.get(); + } + + getScreenOrientation(): ScreenOrientation { + return this.screenOrientation.get(); + } + setScreenOrientation(value: ScreenOrientation) { + if (this.canSetScreenOrientation) { + this.screenOrientation.set(value); + this.androidHost?.setScreenOrientation(value as number); + } + } + + setPedalboardSelectedPlugin(pluginId: number): void { + let pedalboard = this.pedalboard.get(); + if (!pedalboard) { + throw new PiPedalStateError("Pedalboard not loaded."); + } + if (pedalboard.selectedPlugin === pluginId) { + return; // no change. + } + pedalboard.selectedPlugin = pluginId; // naughty, because it doesn't broadcast. + // notify the server. + this.webSocket?.send("setSelectedPedalboardPlugin", { clientId: this.clientId, pluginInstanceId: pluginId }); + + } }; let instance: PiPedalModel | undefined = undefined; diff --git a/vite/src/pipedal/PluginControl.tsx b/vite/src/pipedal/PluginControl.tsx index 3aa0c33..9374188 100644 --- a/vite/src/pipedal/PluginControl.tsx +++ b/vite/src/pipedal/PluginControl.tsx @@ -367,15 +367,15 @@ const PluginControl = } } - if (this.isTouchDevice()) { - if (this.props.uiControl - && (this.props.uiControl.isDial() || this.props.uiControl.isGraphicEq()) - ) { - this.isTap = false; - this.showZoomedControl(); - return; - } - } + // if (this.isTouchDevice()) { + // if (this.props.uiControl + // && (this.props.uiControl.isDial() || this.props.uiControl.isGraphicEq()) + // ) { + // this.isTap = false; + // this.showZoomedControl(); + // return; + // } + // } ++this.pointersDown; diff --git a/vite/src/pipedal/PluginControlView.tsx b/vite/src/pipedal/PluginControlView.tsx index 0a73410..76d483f 100644 --- a/vite/src/pipedal/PluginControlView.tsx +++ b/vite/src/pipedal/PluginControlView.tsx @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Robin Davies +// Copyright (c) 2025 Robin E. R. Davies // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in @@ -23,6 +23,12 @@ import WithStyles, { withTheme } from './WithStyles'; import { createStyles } from './WithStyles'; import { css } from '@emotion/react'; +import AutoZoom from './AutoZoom'; + +import ModGuiHost from './ModGuiHost'; + +import {midiChannelBindingControlFeatureEnabled} from './MidiChannelBinding'; + import { withStyles } from "tss-react/mui"; import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; import { UiPlugin, UiControl, UiFileProperty, UiFrequencyPlot, ScalePoint } from './Lv2Plugin'; @@ -77,10 +83,11 @@ function makeIoPluginInfo(name: string, uri: string): UiPlugin { return result; } -let startPluginInfo: UiPlugin = +export const startPluginInfo: UiPlugin = makeIoPluginInfo("Input", Pedalboard.START_PEDALBOARD_ITEM_URI); -let endPluginInfo: UiPlugin = + +export const endPluginInfo: UiPlugin = makeIoPluginInfo("Output", Pedalboard.END_PEDALBOARD_ITEM_URI); @@ -142,9 +149,9 @@ const styles = (theme: Theme) => createStyles({ flexDirection: "row", flexWrap: "nowrap", paddingTop: "0px", - paddingBottom: "0px", + paddingBottom: "96px", overflowX: "hidden", - overflowY: "auto" + overflowY: "auto", }), vuMeterL: css({ position: "absolute", @@ -171,7 +178,7 @@ const styles = (theme: Theme) => createStyles({ right: 0, top: 0, paddingRight: 6, paddingLeft: 12, - paddingBottom: 12, // cover bottom line of a portgroup in landscape. + paddingBottom: 13, // cover bottom line of a portgroup in landscape. background: theme.mainBackground, zIndex: 3 @@ -340,6 +347,8 @@ export interface PluginControlViewProps extends WithStyles { item: PedalboardItem; customization?: ControlViewCustomization; customizationId?: number; + showModGui: boolean; + onSetShowModGui: (instanceId: number, showModGui: boolean) => void; } type PluginControlViewState = { landscapeGrid: boolean; @@ -349,7 +358,9 @@ type PluginControlViewState = { imeInitialHeight: number; showFileDialog: boolean, dialogFileProperty: UiFileProperty, - dialogFileValue: string + dialogFileValue: string, + modGuiContentReady: boolean, + showModGuiZoomed: boolean, }; const PluginControlView = @@ -360,7 +371,6 @@ const PluginControlView = constructor(props: PluginControlViewProps) { super(props); this.model = PiPedalModelFactory.getInstance(); - this.state = { landscapeGrid: false, imeUiControl: undefined, @@ -369,13 +379,28 @@ const PluginControlView = imeInitialHeight: 0, showFileDialog: false, dialogFileProperty: new UiFileProperty(), - dialogFileValue: "" + dialogFileValue: "", + modGuiContentReady: false, + showModGuiZoomed: false } this.onPedalboardChanged = this.onPedalboardChanged.bind(this); this.onControlValueChanged = this.onControlValueChanged.bind(this); this.onPreviewChange = this.onPreviewChange.bind(this); } + // unmonitorPort: (handle: MonitorPortHandle) => void; + // onValueChanged: (instanceId: number, symbol: string, value: number) => void; + + // monitorPatchProperty( + // instanceId: number, + // propertyUri: string, + // onReceived: PatchPropertyListener + // ): ListenHandle; + + // cancelMonitorPatchProperty(listenHandle: ListenHandle): void; + + // getPatchProperty(instanceId: number, uri: string): Promise; + // setPatchProperty(instanceId: number, uri: string, value: any): Promise onPreviewChange(key: string, value: number): void { @@ -426,7 +451,7 @@ const PluginControlView = imeUiControl: uiControl, imeValue: value, imeCaption: uiControl.name, - imeInitialHeight: window.innerHeight + imeInitialHeight: document.documentElement.clientHeight }); } @@ -739,7 +764,7 @@ const PluginControlView = makeIoPluginInfo("Output", Pedalboard.END_PEDALBOARD_ITEM_URI); midiBindingControl(pedalboardItem: PedalboardItem): ReactNode { - if (!pedalboardItem.midiChannelBinding) { + if ((!pedalboardItem.midiChannelBinding) || (midiChannelBindingControlFeatureEnabled === false)) { return false; } return ( @@ -759,18 +784,72 @@ const PluginControlView = return false; } - render(): ReactNode { - this.controlKeyIndex = 0; - const classes = withStyles.getClasses(this.props); - let pedalboardItem: PedalboardItem; - let pedalboard = this.model.pedalboard.get(); - if (this.props.instanceId === Pedalboard.START_CONTROL) { - pedalboardItem = pedalboard.makeStartItem(); - } else if (this.props.instanceId === Pedalboard.END_CONTROL) { - pedalboardItem = pedalboard.makeEndItem(); - } else { - pedalboardItem = pedalboard.getItem(this.props.instanceId); + + handleModGuiFileProperty(instanceId: number, filePropertyUri: string, selectedFile: string) { + let plugin = this.model.getUiPlugin(this.props.item.uri); + if (!plugin) { + throw (new Error("Plugin not found.")); } + let fileProperty = plugin.getFilePropertyByUri(filePropertyUri); + if (!fileProperty) { + throw (new Error("File property not found.")); + } + this.setState({ + showFileDialog: true, + dialogFileProperty: fileProperty, + dialogFileValue: selectedFile + }); + } + renderModGui(): ReactNode { + const classes = withStyles.getClasses(this.props); + void classes; + + let uiPlugin = this.model.getUiPlugin(this.props.item.uri); + if (!uiPlugin) { + return (
); + } + return ( +
+ { this.setState({ showModGuiZoomed: value }); } + } + > + { + if (this.props.onSetShowModGui) { + this.props.onSetShowModGui(this.props.instanceId, false); + } + this.setState({showModGuiZoomed: false}) + }} + handleFileSelect={(instanceId, fileProperty, selectedFile) => { + this.handleModGuiFileProperty( + instanceId, + fileProperty, + selectedFile); + + }} + onContentReady={(value) => { + this.setState({ modGuiContentReady: value }); + }} + /> + +
+ + ) + } + + renderPiPedalControl(pedalboardItem?: PedalboardItem): ReactNode { + this.controlKeyIndex = 0; + + + const classes = withStyles.getClasses(this.props); + let pedalboard = this.model.pedalboard.get(); if (!pedalboardItem) return (
); @@ -792,19 +871,13 @@ const PluginControlView = controlValues = this.filterNotOnGui(controlValues, plugin); - - let gridClass = this.state.landscapeGrid ? classes.landscapeGrid : classes.normalGrid; let scrollClass = this.state.landscapeGrid ? classes.frameScrollLandscape : classes.frameScrollPortrait; - let vuMeterRClass = this.state.landscapeGrid ? classes.vuMeterRLandscape : classes.vuMeterR; - let controlNodes: ControlNodes; - let frameClass = classes.frame; - if (this.fullScreen()) { gridClass = classes.noScrollGrid; scrollClass = classes.frameScrollNone; - frameClass = classes.noScrollFrame; } + let controlNodes: ControlNodes; controlNodes = this.getStandardControlNodes(plugin, controlValues); @@ -819,9 +892,49 @@ const PluginControlView = pedalboardItem.midiChannelBinding = MidiChannelBinding.CreateMissingValue(); } - if (pedalboardItem.midiChannelBinding) { + if (pedalboardItem.midiChannelBinding && midiChannelBindingControlFeatureEnabled) { nodes.push(this.midiBindingControl(pedalboardItem)); } + return ( +
+
+ { + nodes + } + {/* Extra space to allow scrolling right to the end in lascape especially */} + {!this.fullScreen() && ( +
+ )} + { + (!this.state.landscapeGrid) && (!this.fullScreen()) && ( +
+ ) + } +
+
+ ); + + + } + render(): ReactNode { + const classes = withStyles.getClasses(this.props); + let pedalboard = this.model.pedalboard.get(); + + let pedalboardItem: PedalboardItem; + if (this.props.instanceId === Pedalboard.START_CONTROL) { + pedalboardItem = pedalboard.makeStartItem(); + } else if (this.props.instanceId === Pedalboard.END_CONTROL) { + pedalboardItem = pedalboard.makeEndItem(); + } else { + pedalboardItem = pedalboard.getItem(this.props.instanceId); + } + + let vuMeterRClass = this.state.landscapeGrid ? classes.vuMeterRLandscape : classes.vuMeterR; + let frameClass = classes.frame; + + if (this.fullScreen() || this.props.showModGui) { + frameClass = classes.noScrollFrame; + } return ( @@ -832,22 +945,11 @@ const PluginControlView =
-
-
- { - nodes - } - {/* Extra space to allow scrolling right to the end in lascape especially */} - {!this.fullScreen() && ( -
- )} - { - (!this.state.landscapeGrid) && (!this.fullScreen()) && ( -
- ) - } -
-
+ { + this.props.showModGui && this.renderModGui() + || this.renderPiPedalControl(pedalboardItem) + } + {this.state.showFileDialog && ( { - this.setState({ showFileDialog: false }); + this.setState({ + showFileDialog: false + }); }} onApply={(fileProperty, selectedFile) => { this.model.setPatchProperty( this.props.instanceId, fileProperty.patchProperty, - JsonAtom.Path(selectedFile) + JsonAtom._Path(selectedFile).asAny() ) .then(() => { @@ -876,7 +980,7 @@ const PluginControlView = this.model.setPatchProperty( this.props.instanceId, fileProperty.patchProperty, - JsonAtom.Path(selectedFile) + JsonAtom._Path(selectedFile).asAny() ) .then(() => { @@ -889,7 +993,7 @@ const PluginControlView = } /> )} - + {/* xxx: I don't think we need this anyore. */} this.onImeValueChange(key, value)} diff --git a/vite/src/pipedal/PresetSelector.tsx b/vite/src/pipedal/PresetSelector.tsx index 5491519..a903f79 100644 --- a/vite/src/pipedal/PresetSelector.tsx +++ b/vite/src/pipedal/PresetSelector.tsx @@ -304,7 +304,7 @@ const PresetSelector = }}>
{ this.handleSave(); }} size="large"> diff --git a/vite/src/pipedal/Rect.tsx b/vite/src/pipedal/Rect.tsx index 096f377..8ef44d1 100644 --- a/vite/src/pipedal/Rect.tsx +++ b/vite/src/pipedal/Rect.tsx @@ -31,7 +31,7 @@ class Rect { this.x = x; this.y = y; this.width = width; this.height = height; } - toString() { return '{' + this.x + "," + this.y + " " + this.width + "," + this.height +"}"; } + toString() { return '{' + this.x + "," + this.y + " - " + this.width + "," + this.height +"}"; } copy(): Rect { let result = new Rect(); result.x = this.x; @@ -84,6 +84,12 @@ class Rect { this.x += xOffset; this.y += yOffset; } + equals(other: Rect): boolean { + return this.x === other.x && + this.y === other.y && + this.width === other.width && + this.height === other.height; + } } export default Rect; \ No newline at end of file diff --git a/vite/src/pipedal/ResizeResponsiveComponent.tsx b/vite/src/pipedal/ResizeResponsiveComponent.tsx index a46f6e8..726b352 100644 --- a/vite/src/pipedal/ResizeResponsiveComponent.tsx +++ b/vite/src/pipedal/ResizeResponsiveComponent.tsx @@ -24,8 +24,8 @@ class ResizeResponsiveComponent extends React.Component constructor(props: PROPS) { super(props); this.handleWindowResize = this.handleWindowResize.bind(this); - this.windowSize.width = window.innerWidth; - this.windowSize.height = window.innerHeight; + this.windowSize.width = document.documentElement.clientWidth; + this.windowSize.height = document.documentElement.clientHeight; } windowSize: {width: number,height:number} = {width: 1280, height: 1024}; @@ -34,8 +34,8 @@ class ResizeResponsiveComponent extends React.Component } handleWindowResize() { - let width_ = window.innerWidth; - let height_ = window.innerHeight; + let width_ = document.documentElement.clientWidth; + let height_ = document.documentElement.clientHeight; if (width_ !== this.windowSize.width || height_ !== this.windowSize.height) { this.windowSize = {width: width_, height: height_}; @@ -49,8 +49,8 @@ class ResizeResponsiveComponent extends React.Component componentDidMount() { window.addEventListener('resize', this.handleWindowResize); - let width_ = window.innerWidth; - let height_ = window.innerHeight; + let width_ = document.documentElement.clientWidth; + let height_ = document.documentElement.clientHeight; this.windowSize = {width: width_, height: height_}; this.onWindowSizeChanged(this.windowSize.width,this.windowSize.height); } diff --git a/vite/src/pipedal/ScratchClass.tsx b/vite/src/pipedal/ScratchClass.tsx new file mode 100644 index 0000000..fbe6a6e --- /dev/null +++ b/vite/src/pipedal/ScratchClass.tsx @@ -0,0 +1,91 @@ +// Copyright (c) 2025 Robin E. R. Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import ResizeResponsiveComponent from './ResizeResponsiveComponent'; +import { Theme } from '@mui/material/styles'; +import WithStyles from './WithStyles'; +import { withStyles } from "tss-react/mui"; +import {createStyles} from './WithStyles'; + +import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; + +const styles = (theme: Theme) => createStyles({ + pgraph: { + paddingBottom: 16 + } +}); + + +export interface ScratchClassProps extends WithStyles { +} + +export interface ScratchClassState { + screenWidth: number, + screenHeight: number +} + + + +const ScratchClass = withStyles( + class extends ResizeResponsiveComponent { + private model: PiPedalModel; + constructor(props: ScratchClassProps) { + super(props); + + this.model = PiPedalModelFactory.getInstance(); + void this.model; // suppress unused variable warning + + this.state = { + screenWidth: this.windowSize.width, + screenHeight: this.windowSize.height + }; + } + mounted: boolean = false; + + + + onWindowSizeChanged(width: number, height: number): void { + this.setState({ screenWidth: width, screenHeight: height }); + } + + + componentDidMount() { + super.componentDidMount(); + this.mounted = true; + } + componentWillUnmount() { + this.mounted = false; + super.componentWillUnmount(); + } + + + componentDidUpdate(prevProps: ScratchClassProps) { + } + + + + render() { + const classes = withStyles.getClasses(this.props); + void classes; // suppress unused variable warning + return (
); + } + }, + styles); + +export default ScratchClass; \ No newline at end of file diff --git a/vite/src/pipedal/ScreenOrientation.tsx b/vite/src/pipedal/ScreenOrientation.tsx new file mode 100644 index 0000000..05f3465 --- /dev/null +++ b/vite/src/pipedal/ScreenOrientation.tsx @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +enum ScreenOrientation { + // values must match Pipedal Remote ScreenOrientation.java + SystemDefault = 0, + Landscape = 1, + Portrait = 2 +} + +export default ScreenOrientation; \ No newline at end of file diff --git a/vite/src/pipedal/SelectScreenOrientationDialog.tsx b/vite/src/pipedal/SelectScreenOrientationDialog.tsx new file mode 100644 index 0000000..6a002bf --- /dev/null +++ b/vite/src/pipedal/SelectScreenOrientationDialog.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2022 Robin Davies +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import List from '@mui/material/List'; +import ListItemText from '@mui/material/ListItemText'; +import DialogEx from './DialogEx'; + +import { ListItemButton } from '@mui/material'; +import ScreenOrientation from './ScreenOrientation'; + + + +export interface SelectScreenOrientationDialogProps { + open: boolean; + screenOrientation: ScreenOrientation; + onClose: (screenOrientation: ScreenOrientation | null) => void; +} + + +function SelectScreenOrientationDialog(props: SelectScreenOrientationDialogProps) { + //const classes = useStyles(); + const { onClose, screenOrientation, open } = props; + + const handleCancel = () => { + onClose(null); + } + const handleOk = (value: ScreenOrientation) => { + onClose(value); + }; + + const handleListItemClick = (value: ScreenOrientation) => { + handleOk(value); + }; + + return ( + { }} + > + + + handleListItemClick(ScreenOrientation.SystemDefault)} key={"0"} + selected={screenOrientation === ScreenOrientation.SystemDefault} > + + + handleListItemClick(ScreenOrientation.Portrait)} key={"1"} + selected={screenOrientation === ScreenOrientation.Portrait} > + + + handleListItemClick(ScreenOrientation.Landscape)} key={"2"} + selected={screenOrientation === ScreenOrientation.Landscape} > + + + + + ); +} + +export default SelectScreenOrientationDialog; diff --git a/vite/src/pipedal/SettingsDialog.tsx b/vite/src/pipedal/SettingsDialog.tsx index aa51806..cef8440 100644 --- a/vite/src/pipedal/SettingsDialog.tsx +++ b/vite/src/pipedal/SettingsDialog.tsx @@ -46,10 +46,10 @@ import DialogEx from './DialogEx' import GovernorSettings from './GovernorSettings'; import SystemMidiBindingsDialog from './SystemMidiBindingsDialog'; import SelectThemeDialog from './SelectThemeDialog'; -import {AlsaSequencerConfiguration} from './AlsaSequencer'; +import { AlsaSequencerConfiguration } from './AlsaSequencer'; import Slide, { SlideProps } from '@mui/material/Slide'; -import {createStyles} from './WithStyles'; +import { createStyles } from './WithStyles'; import { Theme } from '@mui/material/styles'; import { withStyles } from "tss-react/mui"; @@ -57,6 +57,8 @@ import WithStyles from './WithStyles'; import { canScaleWindow, getWindowScaleOptions, getWindowScaleText, setWindowScale, getWindowScale } from './WindowScale'; import OptionsDialog from './OptionsDialog'; import { css } from '@emotion/react'; +import ScreenOrientation from './ScreenOrientation'; +import SelectScreenOrientationDialog from './SelectScreenOrientationDialog'; interface SettingsDialogProps extends WithStyles { @@ -74,6 +76,8 @@ interface SettingsDialogState { jackSettings: JackChannelSelection; jackServerSettings: JackServerSettings; alsaSequencerConfiguration: AlsaSequencerConfiguration; + keepScreenOn: boolean; + screenOrientation: ScreenOrientation; jackStatus?: JackHostStatus; governorSettings: GovernorSettings; @@ -88,6 +92,7 @@ interface SettingsDialogState { showGovernorSettingsDialog: boolean; showInputSelectDialog: boolean; showOutputSelectDialog: boolean; + showScreenOrientationDialog: boolean; showMidiSelectDialog: boolean; showThemeSelectDialog: boolean; showJackServerSettingsDialog: boolean; @@ -191,13 +196,15 @@ const SettingsDialog = withStyles( wifiDirectConfigSettings: this.model.wifiDirectConfigSettings.get(), governorSettings: this.model.governorSettings.get(), continueDisabled: true, - + keepScreenOn: this.model.keepScreenOn.get(), + screenOrientation: this.model.getScreenOrientation(), showWindowScaleDialog: false, showWifiConfigDialog: false, showWifiDirectConfigDialog: false, showGovernorSettingsDialog: false, showInputSelectDialog: false, showOutputSelectDialog: false, + showScreenOrientationDialog: false, showMidiSelectDialog: false, showThemeSelectDialog: false, showJackServerSettingsDialog: false, @@ -219,11 +226,21 @@ const SettingsDialog = withStyles( this.handleConnectionStateChanged = this.handleConnectionStateChanged.bind(this); this.handleShowStatusMonitorChanged = this.handleShowStatusMonitorChanged.bind(this); this.handleHasWifiChanged = this.handleHasWifiChanged.bind(this); + this.handleKeepScreenOnChanged = this.handleKeepScreenOnChanged.bind(this); + this.handleScreenOrientationChanged = this.handleScreenOrientationChanged.bind(this); } + handleScreenOrientationChanged(newValue: ScreenOrientation) { + this.setState({ + screenOrientation: newValue + }); + } + handleKeepScreenOnChanged(newValue: boolean) { + this.setState({ keepScreenOn: newValue }); + } handleHasWifiChanged(newValue: boolean) { - this.setState({hasWifiDevice: newValue}); + this.setState({ hasWifiDevice: newValue }); } handleShowStatusMonitorChanged(): void { @@ -337,6 +354,8 @@ const SettingsDialog = withStyles( if (active !== this.active) { this.active = active; if (active) { + this.model.keepScreenOn.addOnChangedHandler(this.handleKeepScreenOnChanged); + this.model.screenOrientation.addOnChangedHandler(this.handleScreenOrientationChanged); this.model.hasWifiDevice.addOnChangedHandler(this.handleHasWifiChanged); this.model.state.addOnChangedHandler(this.handleConnectionStateChanged); this.model.showStatusMonitor.addOnChangedHandler(this.handleShowStatusMonitorChanged); @@ -368,7 +387,7 @@ const SettingsDialog = withStyles( this.handleJackServerSettingsChanged(); this.handleWifiConfigSettingsChanged(); this.handleWifiDirectConfigSettingsChanged(); - this.setState({hasWifiDevice: this.model.hasWifiDevice.get()}); + this.setState({ hasWifiDevice: this.model.hasWifiDevice.get() }); this.timerHandle = setInterval(() => this.tick(), 1000); } else { @@ -377,6 +396,8 @@ const SettingsDialog = withStyles( } this.model.state.removeOnChangedHandler(this.handleConnectionStateChanged); this.model.showStatusMonitor.removeOnChangedHandler(this.handleShowStatusMonitorChanged); + this.model.keepScreenOn.removeOnChangedHandler(this.handleKeepScreenOnChanged); + this.model.screenOrientation.removeOnChangedHandler(this.handleScreenOrientationChanged); this.model.jackConfiguration.removeOnChangedHandler(this.handleJackConfigurationChanged); this.model.alsaSequencerConfiguration.removeOnChangedHandler(this.handleAlsaSequencerConfigurationChanged); @@ -441,6 +462,28 @@ const SettingsDialog = withStyles( showOutputSelectDialog: false }); } + handleScreenOrientation() { + this.setState({ + showScreenOrientationDialog: true, + }); + } + getOrientationText() { + switch (this.state.screenOrientation) { + case ScreenOrientation.Portrait: + return "Portrait"; + case ScreenOrientation.Landscape: + return "Landscape"; + default: + return "System default"; + } + } + handleScreenOrientationDialogResult(orientation: ScreenOrientation | null): void { + if (orientation !== null) { + this.setState({ screenOrientation: orientation }); + this.model.setScreenOrientation(orientation); + } + this.setState({ showScreenOrientationDialog: false }); + } handleOutputSelection() { this.setState({ @@ -548,11 +591,13 @@ const SettingsDialog = withStyles( } render() { const classes = withStyles.getClasses(this.props); - + let isConfigValid = this.state.jackConfiguration.isValid; let selectedChannels: string[] = this.state.showInputSelectDialog ? this.state.jackSettings.inputAudioPorts : this.state.jackSettings.outputAudioPorts; let disableShutdown = this.state.shuttingDown || this.state.restarting; + let canKeepScreenOn = this.model.canKeepScreenOn; + return ( { this.props.onClose() }} TransitionComponent={Transition} @@ -730,6 +775,51 @@ const SettingsDialog = withStyles(
+ {canKeepScreenOn && + ( + { + this.model.setKeepScreenOn(!this.state.keepScreenOn) + }} > + +
+
+
+ + Keep screen on. +
+ +
+ { this.model.setKeepScreenOn(e.target.checked); } + } + /> +
+ +
+
+
+ ) + } + {this.model.canSetScreenOrientation && ( + this.handleScreenOrientation()} + > + +
+ Display Orientation + { + this.getOrientationText() + } +
+
+ ) + } {(canScaleWindow()) && ( @@ -908,6 +998,21 @@ const SettingsDialog = withStyles( ) } + { + this.state.showScreenOrientationDialog && + ( + { + this.handleScreenOrientationDialogResult(screenOrientation); + }} + screenOrientation={this.state.screenOrientation} + + /> + ) + + } + { (this.state.showGovernorSettingsDialog) && ( diff --git a/vite/src/pipedal/SnapshotDialog.tsx b/vite/src/pipedal/SnapshotDialog.tsx index 765de56..1bea229 100644 --- a/vite/src/pipedal/SnapshotDialog.tsx +++ b/vite/src/pipedal/SnapshotDialog.tsx @@ -89,7 +89,7 @@ export default class SnapshotDialog extends ResizeResponsiveComponent 700 && window.innerWidth > 1000; + return document.documentElement.clientHeight > 700 && document.documentElement.clientWidth > 1000; } onSaveSnapshot(index: number) { let snapshot = this.state.snapshots[index]; diff --git a/vite/src/pipedal/SnapshotPropertiesDialog.tsx b/vite/src/pipedal/SnapshotPropertiesDialog.tsx index 9296218..c69128a 100644 --- a/vite/src/pipedal/SnapshotPropertiesDialog.tsx +++ b/vite/src/pipedal/SnapshotPropertiesDialog.tsx @@ -64,7 +64,7 @@ export default class SnapshotPropertiesDialog extends ResizeResponsiveComponent< this.state = this.stateFromProps(); } getCompactVertical() { - return window.innerHeight < 450; + return document.documentElement.clientHeight < 450; } stateFromProps() { let color = this.props.color; diff --git a/vite/src/pipedal/TextFieldEx.tsx b/vite/src/pipedal/TextFieldEx.tsx index 9facc9d..e109e59 100644 --- a/vite/src/pipedal/TextFieldEx.tsx +++ b/vite/src/pipedal/TextFieldEx.tsx @@ -52,7 +52,7 @@ const TextFieldEx = withStyles(styles, { withTheme: true })( } private getUseImeMask() { - return this.state.androidHosted && window.innerHeight < 4500; + return this.state.androidHosted && document.documentElement.clientHeight < 4500; } private originalInputHref: HTMLInputElement | undefined = undefined; diff --git a/vite/src/pipedal/ToobCabSimView.tsx b/vite/src/pipedal/ToobCabSimView.tsx index caa0e3e..74fc3b4 100644 --- a/vite/src/pipedal/ToobCabSimView.tsx +++ b/vite/src/pipedal/ToobCabSimView.tsx @@ -77,6 +77,9 @@ const ToobCabSimView = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui= {(instanceId: number, showModGui: boolean) => {}} + />); } }, diff --git a/vite/src/pipedal/ToobInputStageView.tsx b/vite/src/pipedal/ToobInputStageView.tsx index dd37f4c..91321f7 100644 --- a/vite/src/pipedal/ToobInputStageView.tsx +++ b/vite/src/pipedal/ToobInputStageView.tsx @@ -21,14 +21,14 @@ import React from 'react'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; -import {createStyles} from './WithStyles'; +import { createStyles } from './WithStyles'; import { withStyles } from "tss-react/mui"; import IControlViewFactory from './IControlViewFactory'; import { PiPedalModelFactory, PiPedalModel } from "./PiPedalModel"; import { PedalboardItem } from './Pedalboard'; -import PluginControlView, { ControlGroup,ControlViewCustomization } from './PluginControlView'; +import PluginControlView, { ControlGroup, ControlViewCustomization } from './PluginControlView'; //import ToobFrequencyResponseView from './ToobFrequencyResponseView'; @@ -47,12 +47,11 @@ interface ToobInputStageState { const ToobInputStageView = withStyles( - class extends React.Component - implements ControlViewCustomization - { + class extends React.Component + implements ControlViewCustomization { model: PiPedalModel; - customizationId: number = 1; + customizationId: number = 1; constructor(props: ToobInputStageProps) { super(props); @@ -64,8 +63,7 @@ const ToobInputStageView = return false; } - modifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] - { + modifyControls(controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[] { return controls; // let group = controls[1] as ControlGroup; // group.controls.splice(0,0, @@ -79,6 +77,9 @@ const ToobInputStageView = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui={(instanceId: number, showModGui: boolean) => { }} + />); } }, diff --git a/vite/src/pipedal/ToobMLView.tsx b/vite/src/pipedal/ToobMLView.tsx index d4dd702..378b07f 100644 --- a/vite/src/pipedal/ToobMLView.tsx +++ b/vite/src/pipedal/ToobMLView.tsx @@ -21,7 +21,7 @@ import React from 'react'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; -import {createStyles} from './WithStyles'; +import { createStyles } from './WithStyles'; import { withStyles } from "tss-react/mui"; @@ -48,7 +48,7 @@ const ToobMLView = class extends React.Component implements ControlViewCustomization { model: PiPedalModel; - gainRef: React.RefObject; + gainRef: React.RefObject; customizationId: number = 1; @@ -119,19 +119,17 @@ const ToobMLView = // } let controlIndex: number = -1; let controlValues = this.props.item.controlValues - for (let i:number = 0; i < controlValues.length; ++i) - { + for (let i: number = 0; i < controlValues.length; ++i) { let ctl: any = controls[i]; - let key: any = ctl?.props?.uiControl?.symbol??""; - - if (key === "gain") - { + let key: any = ctl?.props?.uiControl?.symbol ?? ""; + + if (key === "gain") { controlIndex = i; break; } } - if (controlIndex !== -1 ) { + if (controlIndex !== -1) { let gainControl: React.ReactElement = controls[controlIndex] as React.ReactElement; controls[controlIndex] = (
{gainControl}
); @@ -144,6 +142,9 @@ const ToobMLView = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui={(instanceId: number, showModGui: boolean) => { }} + />); } }, diff --git a/vite/src/pipedal/ToobPlayerControl.tsx b/vite/src/pipedal/ToobPlayerControl.tsx index ac80320..b36ab68 100644 --- a/vite/src/pipedal/ToobPlayerControl.tsx +++ b/vite/src/pipedal/ToobPlayerControl.tsx @@ -525,7 +525,7 @@ export default function ToobPlayerControl( function onNextTrack() { model.getNextAudioFile(audioFile) .then((file) => { - let json = JsonAtom.Path(file); + let json = JsonAtom._Path(file).asAny(); model.setPatchProperty( props.instanceId, AUDIO_FILE_PROPERTY_URI, @@ -538,7 +538,7 @@ export default function ToobPlayerControl( function onPreviousTrack() { model.getPreviousAudioFile(audioFile) .then((file) => { - let json = JsonAtom.Path(file); + let json = JsonAtom._Path(file).asAny(); model.setPatchProperty( props.instanceId, AUDIO_FILE_PROPERTY_URI, @@ -873,7 +873,7 @@ export default function ToobPlayerControl( model.setPatchProperty( props.instanceId, AUDIO_FILE_PROPERTY_URI, - JsonAtom.Path(selectedFile) + JsonAtom._Path(selectedFile).asAny() ) .then(() => { @@ -888,7 +888,7 @@ export default function ToobPlayerControl( model.setPatchProperty( props.instanceId, fileProperty.patchProperty, - JsonAtom.Path(selectedFile) + JsonAtom._Path(selectedFile).asAny() ) .then(() => { diff --git a/vite/src/pipedal/ToobPlayerView.tsx b/vite/src/pipedal/ToobPlayerView.tsx index 1f40908..751620c 100644 --- a/vite/src/pipedal/ToobPlayerView.tsx +++ b/vite/src/pipedal/ToobPlayerView.tsx @@ -110,14 +110,14 @@ const ToobPlayerView = for (let mixControl of mixPanel.controls) { extraControls.push( ( -
+
{mixControl}
)); } let panel = ( - + ); let result: (React.ReactNode | ControlGroup)[] = []; @@ -133,6 +133,9 @@ const ToobPlayerView = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui={(instanceId: number, showModGui: boolean) => { }} + /> ); } @@ -146,7 +149,7 @@ class ToobPlayerViewFactory implements IControlViewFactory { uri: string = "http://two-play.com/plugins/toob-player"; Create(model: PiPedalModel, pedalboardItem: PedalboardItem): React.ReactNode { - return (); + return (); } diff --git a/vite/src/pipedal/ToobPowerStage2View.tsx b/vite/src/pipedal/ToobPowerStage2View.tsx index 3d1e8b2..664747d 100644 --- a/vite/src/pipedal/ToobPowerStage2View.tsx +++ b/vite/src/pipedal/ToobPowerStage2View.tsx @@ -179,6 +179,8 @@ const ToobPowerstage2View = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui= {(instanceId: number, showModGui: boolean) => {}} />); } }, diff --git a/vite/src/pipedal/ToobSpectrumAnalyzerView.tsx b/vite/src/pipedal/ToobSpectrumAnalyzerView.tsx index 103d530..269f958 100644 --- a/vite/src/pipedal/ToobSpectrumAnalyzerView.tsx +++ b/vite/src/pipedal/ToobSpectrumAnalyzerView.tsx @@ -21,14 +21,14 @@ import React from 'react'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; -import {createStyles} from './WithStyles'; +import { createStyles } from './WithStyles'; import { withStyles } from "tss-react/mui"; import IControlViewFactory from './IControlViewFactory'; import { PiPedalModelFactory, PiPedalModel } from "./PiPedalModel"; import { PedalboardItem } from './Pedalboard'; -import PluginControlView, { ControlGroup,ControlViewCustomization } from './PluginControlView'; +import PluginControlView, { ControlGroup, ControlViewCustomization } from './PluginControlView'; import ToobSpectrumResponseView from './ToobSpectrumResponseView'; @@ -48,12 +48,11 @@ interface ToobSpectrumAnalyzerState { const ToobSpectrumAnalyzerView = withStyles( - class extends React.Component - implements ControlViewCustomization - { + class extends React.Component + implements ControlViewCustomization { model: PiPedalModel; - customizationId: number = 1; + customizationId: number = 1; constructor(props: ToobSpectrumAnalyzerProps) { super(props); @@ -65,11 +64,10 @@ const ToobSpectrumAnalyzerView = return false; } - modifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] - { - controls.splice(0,0, - ( ) - ); + modifyControls(controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[] { + controls.splice(0, 0, + () + ); return controls; } render() { @@ -78,6 +76,9 @@ const ToobSpectrumAnalyzerView = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui={(instanceId: number, showModGui: boolean) => { }} + />); } }, diff --git a/vite/src/pipedal/ToobToneStackView.tsx b/vite/src/pipedal/ToobToneStackView.tsx index 055177a..bf7dfd7 100644 --- a/vite/src/pipedal/ToobToneStackView.tsx +++ b/vite/src/pipedal/ToobToneStackView.tsx @@ -21,14 +21,14 @@ import React from 'react'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; -import {createStyles} from './WithStyles'; +import { createStyles } from './WithStyles'; import { withStyles } from "tss-react/mui"; import IControlViewFactory from './IControlViewFactory'; -import { PiPedalModelFactory, PiPedalModel,ControlValueChangedHandle } from "./PiPedalModel"; +import { PiPedalModelFactory, PiPedalModel, ControlValueChangedHandle } from "./PiPedalModel"; import { PedalboardItem } from './Pedalboard'; -import PluginControlView, { ControlGroup,ControlViewCustomization } from './PluginControlView'; +import PluginControlView, { ControlGroup, ControlViewCustomization } from './PluginControlView'; import ToobFrequencyResponseView from './ToobFrequencyResponseView'; @@ -47,12 +47,11 @@ interface ToobToneStackState { const ToobToneStackView = withStyles( - class extends React.Component - implements ControlViewCustomization - { + class extends React.Component + implements ControlViewCustomization { model: PiPedalModel; - customizationId: number = 1; + customizationId: number = 1; constructor(props: ToobToneStackProps) { super(props); @@ -61,27 +60,24 @@ const ToobToneStackView = isBaxandall: this.IsBaxandall() } } - IsBaxandall() : boolean { + IsBaxandall(): boolean { return this.props.item.getControl("ampmodel").value === 2.0; } controlValueChangedHandle?: ControlValueChangedHandle; - componentDidMount() - { + componentDidMount() { this.controlValueChangedHandle = this.model.addControlValueChangeListener( this.props.instanceId, - (key,value) => { - if (key === "ampmodel") - { - this.setState({isBaxandall: value === 2.0}); + (key, value) => { + if (key === "ampmodel") { + this.setState({ isBaxandall: value === 2.0 }); } } ); } componentWillUnmount() { - if (this.controlValueChangedHandle) - { + if (this.controlValueChangedHandle) { this.model.removeControlValueChangeListener(this.controlValueChangedHandle); this.controlValueChangedHandle = undefined; } @@ -90,18 +86,16 @@ const ToobToneStackView = return false; } - modifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] - { - if (this.state.isBaxandall) - { - controls.splice(0,0, - ( ) - ); + modifyControls(controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[] { + if (this.state.isBaxandall) { + controls.splice(0, 0, + () + ); } else { - controls.splice(0,0, - ( ) - ); + controls.splice(0, 0, + () + ); } return controls; } @@ -111,6 +105,9 @@ const ToobToneStackView = item={this.props.item} customization={this} customizationId={this.customizationId} + showModGui={false} + onSetShowModGui={(instanceId: number, showModGui: boolean) => { }} + />); } }, diff --git a/vite/src/pipedal/UseWindowSize.tsx b/vite/src/pipedal/UseWindowSize.tsx index e14c206..7fa73f3 100644 --- a/vite/src/pipedal/UseWindowSize.tsx +++ b/vite/src/pipedal/UseWindowSize.tsx @@ -25,8 +25,8 @@ import { useState, useEffect } from 'react'; const getSize = () => { return { - width: window.innerWidth, - height: window.innerHeight, + width: document.documentElement.clientWidth, + height: document.documentElement.clientHeight, }; }; diff --git a/vite/src/pipedal/Utility.tsx b/vite/src/pipedal/Utility.tsx index 50a8d37..47919e0 100644 --- a/vite/src/pipedal/Utility.tsx +++ b/vite/src/pipedal/Utility.tsx @@ -39,13 +39,13 @@ export interface ControlEntry { const Utility = class { static isLandscape(): boolean { - return window.innerWidth > window.innerHeight; + return document.documentElement.clientWidth > document.documentElement.clientHeight; } static needsZoomedControls(): boolean { if (!this.isLandscape()) return false; - if (window.innerHeight > 500) return false; - if (window.innerHeight > 300) return true; + if (document.documentElement.clientHeight > 500) return false; + if (document.documentElement.clientHeight > 300) return true; return (('ontouchstart' in window) && ((navigator.maxTouchPoints??0) > 0) ); } diff --git a/vite/src/pipedal/VirtualKeyboardHandler.tsx b/vite/src/pipedal/VirtualKeyboardHandler.tsx index cb8689f..0d169ab 100644 --- a/vite/src/pipedal/VirtualKeyboardHandler.tsx +++ b/vite/src/pipedal/VirtualKeyboardHandler.tsx @@ -51,7 +51,7 @@ export default class VirtualKeyboardHandler enabled = false; } this.enabled = enabled; - this.originalPosition = new Rectangle(0,0, window.innerWidth,window.innerHeight) + this.originalPosition = new Rectangle(0,0, document.documentElement.clientWidth,document.documentElement.clientHeight) } private handleVisualViewportResize(event: Event): void { const viewport = event.target as VisualViewport; diff --git a/vite/src/pipedal/VuMeter.tsx b/vite/src/pipedal/VuMeter.tsx index c77fbe0..ebcd333 100644 --- a/vite/src/pipedal/VuMeter.tsx +++ b/vite/src/pipedal/VuMeter.tsx @@ -95,7 +95,9 @@ class TelltaleState { } const LEFT_X = BORDER_THICKNESS; -const RIGHT_X = BORDER_THICKNESS + + CHANNEL_WIDTH + BORDER_THICKNESS; +const RIGHT_X = BORDER_THICKNESS + CHANNEL_WIDTH + BORDER_THICKNESS; +const TOP_Y = BORDER_THICKNESS; +const VU_BAR_HEIGHT = DISPLAY_HEIGHT-2*BORDER_THICKNESS; const styles = (theme: Theme) => ({ frame: css({ @@ -119,15 +121,16 @@ const styles = (theme: Theme) => ({ overflow: "hidden", }), monoTextFrame: css({ - display: "flex",flexFlow: "column nowrap", alignItems: "center", textAlign: "center" + display: "flex",flexFlow: "column nowrap", alignItems: "center", textAlign: "center", overflow: "clip", }), stereoTextFrame: css({ - display: "flex",flexFlow: "column nowrap", alignItems: "center", textAlign: "center" + display: "flex",flexFlow: "column nowrap", alignItems: "center", textAlign: "center",overflow: "clip", }), vuTextFrame: css({ color: theme.palette.text.secondary, - display: "table-cell", + display: "div", position: "relative", + overflow: "clip", marginTop: 4, textAlign: "center", width: 20, height: 16, @@ -135,73 +138,60 @@ const styles = (theme: Theme) => ({ justifyContent: "center", alignItems: "center" }), - redFrameL: css({ + frameL: css({ position: "absolute", - top: "100%", + top: TOP_Y , left: LEFT_X, width: CHANNEL_WIDTH, - height: DISPLAY_HEIGHT - 2, - background: "#F00" + height: VU_BAR_HEIGHT, + overflow: "clip" }), - yellowFrameL: css({ - top: "100%", - left: LEFT_X, + frameR: css({ + position: "absolute", + overflow: "clip", + top: TOP_Y, + left: RIGHT_X, width: CHANNEL_WIDTH, - position: "absolute", - height: DISPLAY_HEIGHT - 2, - background: "#CC0" + height: VU_BAR_HEIGHT, }), - greenFrameL: css({ - top: "100%", - left: LEFT_X, + yellowBar: css({ + top: 0, + left: 0, width: CHANNEL_WIDTH, + height: VU_BAR_HEIGHT, position: "absolute", - height: DISPLAY_HEIGHT - 2, - background: "#0C2" + background: "#CC0", + transform: "translateY(" + VU_BAR_HEIGHT + "px)" }), - indicatorL: css({ + greenBar: css({ + top: 0, + left: 0, + width: CHANNEL_WIDTH, + height: VU_BAR_HEIGHT, position: "absolute", - left: LEFT_X, + background: "#0C2", + transform: "translateY(" + VU_BAR_HEIGHT + "px)" + }), + redBar: css({ + top: 0, + left: 0, + width: CHANNEL_WIDTH, + height: VU_BAR_HEIGHT, + position: "absolute", + background: "#F00", + transform: "translateY(" + VU_BAR_HEIGHT + "px)" + }), + indicatorBar: css({ + position: "absolute", + left: 0, width: CHANNEL_WIDTH, height: TELLTALE_HEIGHT + "px", - top: "100%", - background: "#F00" + top: 0, + background: "#F00", + transform: "translateY(" + VU_BAR_HEIGHT + "px)" + }), - redFrameR: css({ - position: "absolute", - top: "100%", - left: RIGHT_X, - width: CHANNEL_WIDTH, - height: DISPLAY_HEIGHT - 2, - background: "#F00" - }), - yellowFrameR: css({ - top: "100%", - left: RIGHT_X, - width: CHANNEL_WIDTH, - position: "absolute", - height: DISPLAY_HEIGHT - 2, - background: "#CC0" - }), - greenFrameR: css({ - top: "100%", - left: RIGHT_X, - width: CHANNEL_WIDTH, - position: "absolute", - height: DISPLAY_HEIGHT - 2, - background: "#0C2" - }), - indicatorR: css({ - position: "absolute", - left: RIGHT_X, - width: CHANNEL_WIDTH, - height: TELLTALE_HEIGHT + "px", - top: "100%", - background: "#F00" - - }) - }); @@ -261,10 +251,12 @@ export const VuMeter = telltaleStateR: TelltaleState = new TelltaleState(); vuInfo?: VuUpdateInfo; - requesttedStereoState: boolean = false; + requestedStereoState: boolean = false; + private currentDisplayValue: string | null = null; updateText(telltaleDb: number) { + if (this.textRef.current) { let displayValue: string; if (telltaleDb <= MIN_DB) @@ -288,16 +280,39 @@ export const VuMeter = } } } - this.textRef.current.innerText = displayValue; + + if (this.currentDisplayValue !== displayValue) + { + this.currentDisplayValue = displayValue; + this.textRef.current.innerText = displayValue; + } } } + private currentVuInfo: VuUpdateInfo | null = null; + + private animationFrameHandle: number | null = null; onVuChanged(vuInfo: VuUpdateInfo): void { + this.currentVuInfo = vuInfo; + if (this.animationFrameHandle == null) + { + this.animationFrameHandle = window.requestAnimationFrame(() => { + this.animationFrameHandle = null; + this.animateVuUpdate(); + }); + } + } + private animateVuUpdate() : void + { let value: number; let valueR: number; - - this.vuInfo = vuInfo; - + let vuInfo = this.currentVuInfo; + if (!vuInfo) { + return; // no vu info. + } + if (!this.divRef.current) { // unmounted? + return; + } let isStereo: boolean; @@ -312,34 +327,37 @@ export const VuMeter = } if (this.state.isStereo !== isStereo) { - if (isStereo !== this.requesttedStereoState) // guard against overrunning the layout engine. + if (isStereo !== this.requestedStereoState) // guard against overrunning the layout engine. { - this.requesttedStereoState = isStereo; + this.requestedStereoState = isStereo; this.setState({ isStereo: isStereo }); - return; // we can't procede without stereo layout. + return; // we can't proceed without stereo layout. } } let childNodes = this.divRef.current!.childNodes; + let leftFrameChildNodes = childNodes[0].childNodes; + let vuData: VuChannelData = { value: value, - redDiv: childNodes[0] as HTMLDivElement, - yellowDiv: childNodes[1] as HTMLDivElement, - greenDiv: childNodes[2] as HTMLDivElement, - telltaleDiv: childNodes[3] as HTMLDivElement + redDiv: leftFrameChildNodes[0] as HTMLDivElement, + yellowDiv: leftFrameChildNodes[1] as HTMLDivElement, + greenDiv: leftFrameChildNodes[2] as HTMLDivElement, + telltaleDiv: leftFrameChildNodes[3] as HTMLDivElement }; this.updateChannel(vuData,this.telltaleStateL); if (this.state.isStereo) { + let rightFrameChildren = childNodes[1].childNodes; vuData = { value: valueR, - redDiv: childNodes[4] as HTMLDivElement, - yellowDiv: childNodes[5] as HTMLDivElement, - greenDiv: childNodes[6] as HTMLDivElement, - telltaleDiv: childNodes[7] as HTMLDivElement + redDiv: rightFrameChildren[0] as HTMLDivElement, + yellowDiv: rightFrameChildren[1] as HTMLDivElement, + greenDiv: rightFrameChildren[2] as HTMLDivElement, + telltaleDiv: rightFrameChildren[3] as HTMLDivElement }; this.updateChannel(vuData, this.telltaleStateR); } @@ -367,17 +385,17 @@ export const VuMeter = let INVISIBLE_Y = INTERIOR_DISPLAY_HEIGHT + "px"; if (y >= this.yYellow) { // green only. - vuData.greenDiv.style.top = y + "px"; - vuData.yellowDiv.style.top = INVISIBLE_Y; - vuData.redDiv.style.top = INVISIBLE_Y; + vuData.greenDiv.style.transform = `translateY(${y}px)`; + vuData.yellowDiv.style.transform = `translateY(${INVISIBLE_Y})`; + vuData.redDiv.style.transform = `translateY(${INVISIBLE_Y})`; } else { - vuData.greenDiv.style.top = this.yYellow + "px"; + vuData.greenDiv.style.transform = `translateY(${this.yYellow}px)`; if (y >= this.yZero) { - vuData.yellowDiv.style.top = y + "px"; - vuData.redDiv.style.top = INVISIBLE_Y; + vuData.yellowDiv.style.transform = `translateY(${y}px)`; + vuData.redDiv.style.transform = `translateY(${INVISIBLE_Y})`; } else { - vuData.yellowDiv.style.top = this.yZero + "px"; - vuData.redDiv.style.top = y + "px"; + vuData.yellowDiv.style.transform = `translateY(${this.yZero}px)`; + vuData.redDiv.style.transform = `translateY(${y}px)`; } } let dbTelltale = telltaleState.getTelltaleHoldValue(db); @@ -399,7 +417,7 @@ export const VuMeter = yTelltale = INTERIOR_DISPLAY_HEIGHT; } } - vuData.telltaleDiv.style.top = yTelltale + "px"; + vuData.telltaleDiv.style.transform = `translateY(${yTelltale}px)`; } @@ -467,23 +485,28 @@ export const VuMeter = if (!this.state.isStereo) { return (
-
-
-
-
+
+
+
+
+
+
); } else { return (
-
-
-
-
- -
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
); diff --git a/vite/src/pipedal/WindowScale.tsx b/vite/src/pipedal/WindowScale.tsx index aac1d4f..dd708da 100644 --- a/vite/src/pipedal/WindowScale.tsx +++ b/vite/src/pipedal/WindowScale.tsx @@ -15,7 +15,7 @@ export function getValidWindowScales(): number[] return [1.0]; } let result: number[] = []; - let minDimension = Math.min(window.innerHeight,window.innerWidth); + let minDimension = Math.min(document.documentElement.clientHeight,document.documentElement.clientWidth); for (let validScale of validScales) { diff --git a/vite/src/pipedal/ZoomedUiControl.tsx b/vite/src/pipedal/ZoomedUiControl.tsx index 3d21ffe..1432c86 100644 --- a/vite/src/pipedal/ZoomedUiControl.tsx +++ b/vite/src/pipedal/ZoomedUiControl.tsx @@ -96,7 +96,10 @@ const ZoomedUiControl = withTheme(withStyles( } else { let uiControl = this.props.controlInfo.uiControl; let instanceId = this.props.controlInfo.instanceId; - if (instanceId === -1) return 0; + if (instanceId === -2 && uiControl.symbol === "volume_db") { + return this.model.pedalboard.get()?.input_volume_db??0; + } + let pedalboardItem = this.model.pedalboard.get()?.getItem(instanceId); let value: number = pedalboardItem?.getControlValue(uiControl.symbol) ?? 0; return value; diff --git a/vite/src/pipedal/svg/mod_ui.svg b/vite/src/pipedal/svg/mod_ui.svg new file mode 100644 index 0000000..89f032a --- /dev/null +++ b/vite/src/pipedal/svg/mod_ui.svg @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/vite/src/pipedal/svg/pp_ui.svg b/vite/src/pipedal/svg/pp_ui.svg new file mode 100644 index 0000000..16bd714 --- /dev/null +++ b/vite/src/pipedal/svg/pp_ui.svg @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/vite/vite.config.ts b/vite/vite.config.ts index 8a12749..62dcc1e 100644 --- a/vite/vite.config.ts +++ b/vite/vite.config.ts @@ -8,4 +8,12 @@ export default defineConfig({ chunkSizeWarningLimit: 2000 }, plugins: [react(),svgr()], + server: { + proxy: { + '/resources': { + target: 'http://localhost:8080', + changeOrigin: false, + }, + } +} })