From 06a12dbc21d47f799d6323a082650ea8c989e5c7 Mon Sep 17 00:00:00 2001 From: Daniel Spears <39783756+dspears312@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:57:28 +0200 Subject: [PATCH] Add organ selection and audio configuration dialog - Add OrganSelectionDialog and OrganAudioSettingsDialog with tabbed browsing (Installed Organs, Install Packages, Configure Audio) - Real-time estimated RAM footprint and system headroom bar - Per-organ audio configuration (.mporgan) with presets, storage formats, release streaming, and engine switches - Organ package discovery, validation, and background unrar extraction - Recent organs history, hiding/unhiding organs, and metadata display - Packaging and CI integration for unrar binary bundling on macOS, Windows, and Linux - Unit tests covering configuration persistence, RAM estimation, package discovery, and organ management --- .github/workflows/build.yml | 12 +- .gitignore | 4 + apps/MasterpieceApp/Main.cpp | 4 +- cmake/Packaging.cmake | 8 + src/mp_audio/CMakeLists.txt | 2 + src/mp_audio/MasterpieceProcessor.cpp | 105 +- src/mp_audio/MasterpieceProcessor.h | 20 + src/mp_ui/OrganDialog.cpp | 2512 +++++++++++++++++++++++++ src/mp_ui/OrganDialog.h | 319 ++++ src/mp_ui/Ui.cpp | 24 +- src/mp_ui/Ui.h | 5 +- tests/test_core.cpp | 394 ++++ 12 files changed, 3390 insertions(+), 19 deletions(-) create mode 100644 src/mp_ui/OrganDialog.cpp create mode 100644 src/mp_ui/OrganDialog.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b3a363..0bc4f97 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -116,7 +116,9 @@ jobs: - name: Dependencies (Windows) if: runner.os == 'Windows' - run: choco install ninja --no-progress -y + run: | + choco install ninja --no-progress -y + choco install unrar --no-progress -y - name: MSVC environment if: runner.os == 'Windows' @@ -213,6 +215,12 @@ jobs: src=build/${{ matrix.preset }}/apps/MasterpiecePlugin/MasterpiecePlugin_artefacts/Release/$f [ -e "$src" ] && cp -R "$src" "$stage/" done + # Bundle nonfree unrar binary into app bundle + UNRAR_ARCH="${{ matrix.macarch == 'arm64' && 'arm' || 'x64' }}" + curl -sL "https://www.rarlab.com/rar/rarmacos-${UNRAR_ARCH}-723.tar.gz" | tar -xzf - rar/unrar + chmod +x rar/unrar + cp rar/unrar "$stage/Masterpiece.app/Contents/MacOS/unrar" + rm -rf rar mkdir -p dist ditto -c -k --keepParent "$stage" "dist/${{ matrix.artifact }}.zip" ls -lh dist @@ -264,7 +272,7 @@ jobs: $files = Get-ChildItem $dest -Recurse -File | ForEach-Object { $_.FullName.Substring($dest.Length + 1) } | Sort-Object $files | ForEach-Object { Write-Host " installed: $_" } - $expected = @("bin\Masterpiece.exe", "Uninstall.exe") + $expected = @("bin\Masterpiece.exe", "bin\unrar.exe", "Uninstall.exe") if (Compare-Object $files $expected) { throw "installed files differ from: $($expected -join ', ')" } if (-not (Test-Path $key)) { throw "not registered for Add/Remove Programs" } $shortcuts = $menus | Where-Object { Test-Path "$_\Masterpiece.lnk" } diff --git a/.gitignore b/.gitignore index 262522c..daa859e 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,7 @@ Thumbs.db # The demonstration recording is fetched from the release at deploy time. site/recital.mp4 + +# Nonfree binaries (bundled in releases, never checked into source control) +unrar +unrar.exe diff --git a/apps/MasterpieceApp/Main.cpp b/apps/MasterpieceApp/Main.cpp index d16fc59..020c68e 100644 --- a/apps/MasterpieceApp/Main.cpp +++ b/apps/MasterpieceApp/Main.cpp @@ -414,7 +414,7 @@ class MasterpieceApp : public juce::JUCEApplication { // from a script rather than from the settings page. All three apply to // the NEXT load, which is why they are read before loadOrgan below. // - // --storage int24|int16 what a resident frame costs + // --storage int24|int16 what a resident frame costs // --load-mono on fold a stereo set to one channel // --load-rate 48000 convert as it loads (0 = as recorded) // --cache single|off keep the decoded samples for the next load @@ -654,7 +654,7 @@ class MasterpieceApp : public juce::JUCEApplication { panel->setSize(560, 520); // Opening the organ is the application's business: the file dialog and // what happens after a load both live out here. - panel->onOpenOrgan = [this] { editor_->chooseAndLoadOrgan(); }; + panel->onOpenOrgan = [this] { editor_->showOrganDialog(); }; juce::DialogWindow::LaunchOptions opts; opts.content.setOwned(panel.release()); opts.dialogTitle = "Welcome to Masterpiece"; diff --git a/cmake/Packaging.cmake b/cmake/Packaging.cmake index 88b19cf..a55f205 100644 --- a/cmake/Packaging.cmake +++ b/cmake/Packaging.cmake @@ -46,6 +46,10 @@ if(WIN32) # a resource, which install(TARGETS) insists on a destination for. The icon # is already inside the executable. install(PROGRAMS "$" DESTINATION bin COMPONENT app) + find_program(UNRAR_EXECUTABLE NAMES unrar.exe unrar PATHS "C:/ProgramData/chocolatey/bin" "${CMAKE_BINARY_DIR}") + if(UNRAR_EXECUTABLE) + install(PROGRAMS "${UNRAR_EXECUTABLE}" DESTINATION bin COMPONENT app) + endif() set(CPACK_GENERATOR "NSIS") set(CPACK_PACKAGE_FILE_NAME "masterpiece-windows-setup") @@ -103,6 +107,10 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") # Lower case on the command line, as every other program there is. install(PROGRAMS "$" DESTINATION ${CMAKE_INSTALL_BINDIR} RENAME masterpiece COMPONENT app) + find_program(UNRAR_EXECUTABLE NAMES unrar PATHS "/usr/bin" "/usr/local/bin" "${CMAKE_BINARY_DIR}") + if(UNRAR_EXECUTABLE) + install(PROGRAMS "${UNRAR_EXECUTABLE}" DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT app) + endif() install(FILES "${_mp_pkg_dir}/masterpiece.desktop" DESTINATION ${CMAKE_INSTALL_DATADIR}/applications COMPONENT app) install(FILES "${_mp_pkg_dir}/masterpiece.png" diff --git a/src/mp_audio/CMakeLists.txt b/src/mp_audio/CMakeLists.txt index 172e5cc..5aee7d8 100644 --- a/src/mp_audio/CMakeLists.txt +++ b/src/mp_audio/CMakeLists.txt @@ -25,6 +25,8 @@ add_library(mp_audio STATIC ${CMAKE_CURRENT_SOURCE_DIR}/../mp_ui/LoadingDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../mp_ui/ManualDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/../mp_ui/ManualDialog.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../mp_ui/OrganDialog.h + ${CMAKE_CURRENT_SOURCE_DIR}/../mp_ui/OrganDialog.cpp ) target_include_directories(mp_audio PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/..) target_compile_features(mp_audio PUBLIC cxx_std_20) diff --git a/src/mp_audio/MasterpieceProcessor.cpp b/src/mp_audio/MasterpieceProcessor.cpp index 701b431..5f80f63 100644 --- a/src/mp_audio/MasterpieceProcessor.cpp +++ b/src/mp_audio/MasterpieceProcessor.cpp @@ -738,13 +738,17 @@ std::string MasterpieceProcessor::organKeyFor(const juce::File& odf) { "-" + pathHash(odf); } +juce::File MasterpieceProcessor::dataDirectory() { + return juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) + .getChildFile("Masterpiece"); +} + juce::File MasterpieceProcessor::organFileForSaving( const juce::String& folder, const juce::String& extension) const { if (loadedOdf_.getFullPathName().isEmpty()) return {}; // Always the organ's own identity, even when a legacy file was read: the // point of the migration is that it happens once. - return juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) - .getChildFile("Masterpiece") + return dataDirectory() .getChildFile(folder) .getChildFile(juce::String(organKey()) + extension); } @@ -755,10 +759,7 @@ juce::File MasterpieceProcessor::organFile(const juce::File& odf, if (odf.getFullPathName().isEmpty()) return {}; // Beside the player's own data, never inside the sample set: writing into a // licensed package is not ours to do. - const auto dir = - juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) - .getChildFile("Masterpiece") - .getChildFile(folder); + const auto dir = dataDirectory().getChildFile(folder); // Named by the organ's own identity, so moving or renaming the sample set // does not orphan everything the player configured for it. @@ -1171,9 +1172,7 @@ bool MasterpieceProcessor::loadSettingsFor(const juce::File& odf) { } juce::File MasterpieceProcessor::globalSettingsFile() const { - return juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) - .getChildFile("Masterpiece") - .getChildFile("settings.mpglobal"); + return dataDirectory().getChildFile("settings.mpglobal"); } bool MasterpieceProcessor::writeGlobalFile() const { @@ -1189,6 +1188,14 @@ bool MasterpieceProcessor::writeGlobalFile() const { text << "cachedir " << cacheDir_.getFullPathName() << "\n"; if (lastOrgan_.getFullPathName().isNotEmpty()) text << "lastorgan " << lastOrgan_.getFullPathName() << "\n"; + for (const auto& f : recentOrgans_) { + if (f.getFullPathName().isNotEmpty() && f != lastOrgan_) + text << "recentorgan " << f.getFullPathName() << "\n"; + } + for (const auto& f : hiddenOrgans_) { + if (f.getFullPathName().isNotEmpty()) + text << "hiddenorgan " << f.getFullPathName() << "\n"; + } // Favourites are global by nature: the point of one is to get to a // DIFFERENT organ, so storing them inside the organ being left would be @@ -1236,6 +1243,18 @@ bool MasterpieceProcessor::loadGlobalDefaults() { cacheDir_ = val.isEmpty() ? juce::File() : juce::File(val); } else if (key == "lastorgan") { lastOrgan_ = juce::File(val); + } else if (key == "recentorgan") { + if (val.isNotEmpty()) { + const juce::File f(val); + if (std::find(recentOrgans_.begin(), recentOrgans_.end(), f) == recentOrgans_.end()) + recentOrgans_.push_back(f); + } + } else if (key == "hiddenorgan") { + if (val.isNotEmpty()) { + const juce::File f(val); + if (std::find(hiddenOrgans_.begin(), hiddenOrgans_.end(), f) == hiddenOrgans_.end()) + hiddenOrgans_.push_back(f); + } } else if (key == "favourite") { // "favourite | ". The bar separates them // because both halves are free text and the target can contain spaces; @@ -1256,6 +1275,11 @@ bool MasterpieceProcessor::loadGlobalDefaults() { body << line << "\n"; } } + if (lastOrgan_.existsAsFile()) { + auto it = std::find(recentOrgans_.begin(), recentOrgans_.end(), lastOrgan_); + if (it != recentOrgans_.end()) recentOrgans_.erase(it); + recentOrgans_.insert(recentOrgans_.begin(), lastOrgan_); + } graph_.engineSwitch = sw; globalBody_ = body; return true; @@ -1284,7 +1308,66 @@ int MasterpieceProcessor::addCurrentOrganToFavourites(int slot) { return use; } +void MasterpieceProcessor::addRecentOrgan(const juce::File& odf) { + if (odf == juce::File() || odf.getFullPathName().isEmpty()) return; + auto it = std::find(recentOrgans_.begin(), recentOrgans_.end(), odf); + if (it != recentOrgans_.end()) + recentOrgans_.erase(it); + recentOrgans_.insert(recentOrgans_.begin(), odf); + if (recentOrgans_.size() > 50) + recentOrgans_.resize(50); + writeGlobalFile(); +} + +void MasterpieceProcessor::removeRecentOrgan(const juce::File& odf) { + auto it = std::find(recentOrgans_.begin(), recentOrgans_.end(), odf); + if (it != recentOrgans_.end()) { + recentOrgans_.erase(it); + writeGlobalFile(); + } +} + +void MasterpieceProcessor::hideOrgan(const juce::File& odf) { + if (odf.getFullPathName().isEmpty()) return; + if (std::find(hiddenOrgans_.begin(), hiddenOrgans_.end(), odf) == hiddenOrgans_.end()) { + hiddenOrgans_.push_back(odf); + writeGlobalFile(); + } +} + +void MasterpieceProcessor::unhideOrgan(const juce::File& odf) { + auto it = std::find(hiddenOrgans_.begin(), hiddenOrgans_.end(), odf); + if (it != hiddenOrgans_.end()) { + hiddenOrgans_.erase(it); + writeGlobalFile(); + } +} + +bool MasterpieceProcessor::isOrganHidden(const juce::File& odf) const { + return std::find(hiddenOrgans_.begin(), hiddenOrgans_.end(), odf) != hiddenOrgans_.end(); +} + +void MasterpieceProcessor::unloadOrgan() { + loadedOdf_ = juce::File(); + model_ = OrganModel(); + organRootDir_.clear(); + stopsBySwitch_.clear(); + switches_.reset(model_); + controls_.reset(model_); + samples_.clear(); + engagedSwitches_.clear(); + buildPalletIndex(); + combinations_.reset(model_); + stepper_.reset(model_); +} + void MasterpieceProcessor::setLastOrgan(const juce::File& odf) { + if (odf.existsAsFile()) { + auto it = std::find(recentOrgans_.begin(), recentOrgans_.end(), odf); + if (it != recentOrgans_.end()) recentOrgans_.erase(it); + recentOrgans_.insert(recentOrgans_.begin(), odf); + if (recentOrgans_.size() > 50) recentOrgans_.resize(50); + } if (lastOrgan_ == odf) return; lastOrgan_ = odf; writeGlobalFile(); @@ -1305,9 +1388,7 @@ void MasterpieceProcessor::setReopenLastOrgan(bool on) { } juce::File MasterpieceProcessor::defaultCacheDirectory() { - return juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) - .getChildFile("Masterpiece") - .getChildFile("cache"); + return dataDirectory().getChildFile("cache"); } juce::File MasterpieceProcessor::cacheDirectory() const { diff --git a/src/mp_audio/MasterpieceProcessor.h b/src/mp_audio/MasterpieceProcessor.h index 7d3904a..d31f1fa 100644 --- a/src/mp_audio/MasterpieceProcessor.h +++ b/src/mp_audio/MasterpieceProcessor.h @@ -472,6 +472,7 @@ class MasterpieceProcessor : public juce::AudioProcessor { // What to reopen when the program starts with no organ named. Held with the // global defaults because it belongs to the program, not to any one organ. juce::File lastOrgan() const; + juce::File loadedOdf() const { return loadedOdf_; } bool reopenLastOrgan() const { return reopenLastOrgan_; } void setReopenLastOrgan(bool on); // Audible load progress: a swift tap at each 10% of a load. Off unless @@ -486,6 +487,23 @@ class MasterpieceProcessor : public juce::AudioProcessor { void setCacheDirectory(const juce::File& dir); static juce::File defaultCacheDirectory(); juce::File cacheDirectorySetting() const { return cacheDir_; } + + // The root data directory for Masterpiece settings, cache, and installed organs. + static juce::File dataDirectory(); + + // Known / previously used ODF locations. Global, saved in settings.mpglobal. + const std::vector& recentOrgans() const { return recentOrgans_; } + void addRecentOrgan(const juce::File& odf); + void removeRecentOrgan(const juce::File& odf); + + // Hidden / ignored organs (e.g. removed from list while keeping files on disk) + const std::vector& hiddenOrgans() const { return hiddenOrgans_; } + void hideOrgan(const juce::File& odf); + void unhideOrgan(const juce::File& odf); + bool isOrganHidden(const juce::File& odf) const; + + // Unload currently loaded organ + void unloadOrgan(); void setLoadTicks(bool on); // Session-only form of the above: flips the switch without writing the // global file. Headless tools use this so a measurement render never @@ -925,6 +943,8 @@ class MasterpieceProcessor : public juce::AudioProcessor { // threshold in a few blocks; without this it machine-guns ten taps. double loadTickCooldown_ = 0.0; juce::File lastOrgan_; + std::vector recentOrgans_; + std::vector hiddenOrgans_; // One writer and one reader for the keys both settings tiers share, so the // global defaults and an organ's own file cannot drift apart. juce::String settingsBody() const; diff --git a/src/mp_ui/OrganDialog.cpp b/src/mp_ui/OrganDialog.cpp new file mode 100644 index 0000000..e8c5ada --- /dev/null +++ b/src/mp_ui/OrganDialog.cpp @@ -0,0 +1,2512 @@ +#include "OrganDialog.h" +#include "Ui.h" +#include "../mp_audio/MasterpieceProcessor.h" +#include "../mp_core/OdfLoader.h" +#include +#include +#include +#include +#include + +namespace mp::ui { + +juce::String humaniseEta(double secs) { + if (secs < 45.0) return "less than a minute"; + const int mins = static_cast(secs / 60.0 + 0.5); + if (mins <= 1) return "about a minute"; + return "about " + juce::String(mins) + " minutes"; +} + +juce::String formatByteSize(juce::int64 bytes) { + if (bytes <= 0) return "0 B"; + if (bytes < 1024) return juce::String(bytes) + " B"; + if (bytes < 1024 * 1024) + return juce::String(bytes / 1024.0, 1) + " KB"; + if (bytes < 1024 * 1024 * 1024) + return juce::String(bytes / (1024.0 * 1024.0), 1) + " MB"; + return juce::String(bytes / (1024.0 * 1024.0 * 1024.0), 2) + " GB"; +} + +juce::int64 computeDirectorySize(const juce::File& dir) { + if (!dir.isDirectory()) return 0; + static std::unordered_map> cache; + const auto path = dir.getFullPathName().toStdString(); + const auto mtime = dir.getLastModificationTime().toMilliseconds(); + auto it = cache.find(path); + if (it != cache.end() && it->second.first == mtime) { + return it->second.second; + } + juce::int64 total = 0; + for (const auto& iter : juce::RangedDirectoryIterator(dir, true, "*", juce::File::findFiles)) { + total += iter.getFile().getSize(); + } + cache[path] = {mtime, total}; + return total; +} + +static juce::String packageDirName(uint32_t packageId) { + juce::String s = juce::String(packageId); + while (s.length() < 6) s = "0" + s; + return s; +} + +OrganEntry getOrganDetails(const juce::File& odfFile, const MasterpieceProcessor& proc) { + OrganEntry entry; + entry.file = odfFile; + entry.exists = odfFile.existsAsFile(); + entry.isCurrent = (proc.loadedOdf() == odfFile); + entry.odfSizeBytes = entry.exists ? odfFile.getSize() : 0; + entry.diskSpaceBytes = entry.odfSizeBytes; + + if (!entry.exists) { + entry.name = odfFile.getFileNameWithoutExtension(); + return entry; + } + + pugi::xml_document doc; + const auto fileSize = odfFile.getSize(); + bool parsedFullDoc = false; + if (fileSize > 0) { + std::unique_ptr stream(odfFile.createInputStream()); + if (stream != nullptr) { + juce::MemoryBlock block; + if (fileSize <= 64 * 1024 * 1024) { + block.setSize(static_cast(fileSize)); + stream->read(block.getData(), static_cast(fileSize)); + } else { + const int bytesToRead = 16 * 1024 * 1024; + block.setSize(static_cast(bytesToRead)); + stream->read(block.getData(), bytesToRead); + } + + pugi::xml_parse_result res = doc.load_buffer(block.getData(), block.getSize(), + pugi::parse_default, pugi::encoding_utf8); + if (!res) { + pugi::xml_document docLatin; + res = docLatin.load_buffer(block.getData(), block.getSize(), + pugi::parse_default, pugi::encoding_latin1); + if (res) { + doc.reset(docLatin); + parsedFullDoc = (fileSize <= 64 * 1024 * 1024); + } + } else { + parsedFullDoc = (fileSize <= 64 * 1024 * 1024); + } + } + } + + pugi::xml_node hwNode = doc.child("Hauptwerk"); + for (pugi::xml_node list : hwNode.children("ObjectList")) { + const std::string objType = list.attribute("ObjectType").value(); + if (objType == "_General" || objType == "_general") { + for (pugi::xml_node g : list.children()) { + if (entry.name.isEmpty()) { + pugi::xml_node n = g.child("Identification_Name"); + if (!n) n = g.child("Identification_OrganName"); + if (!n) n = g.child("Name"); + if (n && n.text().as_string()[0] != '\0') + entry.name = juce::String::fromUTF8(n.text().as_string()).trim(); + } + if (entry.uniqueOrganId.isEmpty()) { + pugi::xml_node uid = g.child("Identification_UniqueOrganID"); + if (!uid) uid = g.child("UniqueOrganID"); + if (uid && uid.text().as_string()[0] != '\0') + entry.uniqueOrganId = juce::String::fromUTF8(uid.text().as_string()).trim(); + } + } + } + } + + if (entry.name.isEmpty()) { + entry.name = odfFile.getFileNameWithoutExtension(); + } + + std::string rootStr = mp::deriveOrganRoot(odfFile.getFullPathName().toStdString()); + juce::File organRoot(rootStr); + entry.organRootDir = organRoot; + + std::unordered_map pkgMap; + for (pugi::xml_node list : hwNode.children("ObjectList")) { + const std::string objType = list.attribute("ObjectType").value(); + if (objType == "RequiredInstallationPackage" || objType == "_RequiredInstallationPackage") { + for (pugi::xml_node row : list.children()) { + pugi::xml_node idNode = row.child("InstallationPackageID"); + if (!idNode) idNode = row.child("PackageID"); + if (!idNode) idNode = row.child("Package_PackageID"); + if (idNode) { + uint32_t pkgId = static_cast(idNode.text().as_uint(0)); + if (pkgId > 0) { + OrganPackageInfo pkg; + pkg.packageId = pkgId; + pugi::xml_node nameNode = row.child("Name"); + if (!nameNode) nameNode = row.child("PackageName"); + if (!nameNode) nameNode = row.child("Identification_Name"); + if (nameNode) pkg.name = juce::String::fromUTF8(nameNode.text().as_string()).trim(); + + pugi::xml_node supNode = row.child("SupplierName"); + if (!supNode) supNode = row.child("Supplier"); + if (supNode) pkg.supplierName = juce::String::fromUTF8(supNode.text().as_string()).trim(); + + pkgMap[pkgId] = std::move(pkg); + } + } + } + } + } + + if (pkgMap.empty()) { + for (pugi::xml_node list : hwNode.children("ObjectList")) { + const std::string objType = list.attribute("ObjectType").value(); + if (objType == "Sample" || objType == "sample") { + for (pugi::xml_node s : list.children()) { + pugi::xml_node idNode = s.child("InstallationPackageID"); + if (idNode) { + uint32_t pkgId = static_cast(idNode.text().as_uint(0)); + if (pkgId > 0 && pkgMap.find(pkgId) == pkgMap.end()) { + OrganPackageInfo pkg; + pkg.packageId = pkgId; + pkg.name = "Sample Package " + juce::String(pkgId); + pkgMap[pkgId] = std::move(pkg); + } + } + } + } + } + } + + // Fast text regex fallback for ODF files where RequiredInstallationPackage was not in DOM + if (pkgMap.empty() && entry.exists) { + const auto fullText = odfFile.loadFileAsString(); + const std::string textStd = fullText.toStdString(); + + static const std::regex reqPkgRegex( + "]*>([\\s\\S]*?)", + std::regex_constants::icase); + auto words_begin = std::sregex_iterator(textStd.begin(), textStd.end(), reqPkgRegex); + auto words_end = std::sregex_iterator(); + + static const std::regex idRegex("<(?:InstallationPackageID|PackageID)>(\\d+)([^<]+)([^<]+)(std::stoul(idMatch.str(1))); + if (pkgId > 0 && pkgMap.find(pkgId) == pkgMap.end()) { + OrganPackageInfo pkg; + pkg.packageId = pkgId; + std::smatch nameMatch; + if (std::regex_search(chunk, nameMatch, nameRegex)) { + pkg.name = juce::String::fromUTF8(nameMatch.str(1).c_str()).trim(); + } + std::smatch supMatch; + if (std::regex_search(chunk, supMatch, supRegex)) { + pkg.supplierName = juce::String::fromUTF8(supMatch.str(1).c_str()).trim(); + } + pkgMap[pkgId] = std::move(pkg); + } + } + } + + if (pkgMap.empty()) { + auto s_begin = std::sregex_iterator(textStd.begin(), textStd.end(), idRegex); + for (std::sregex_iterator i = s_begin; i != words_end; ++i) { + std::smatch match = *i; + uint32_t pkgId = static_cast(std::stoul(match.str(1))); + if (pkgId > 0 && pkgMap.find(pkgId) == pkgMap.end()) { + OrganPackageInfo pkg; + pkg.packageId = pkgId; + pkg.name = "Sample Package " + juce::String(pkgId); + pkgMap[pkgId] = std::move(pkg); + } + } + } + } + + const auto dataDir = MasterpieceProcessor::dataDirectory(); + for (auto& [id, pkg] : pkgMap) { + juce::String digits = packageDirName(id); + juce::File pkgDir = organRoot.getChildFile("OrganInstallationPackages").getChildFile(digits); + if (!pkgDir.isDirectory()) { + pkgDir = dataDir.getChildFile("OrganInstallationPackages").getChildFile(digits); + } + pkg.directory = pkgDir; + pkg.isInstalled = pkgDir.isDirectory(); + if (pkg.isInstalled) { + pkg.diskSizeBytes = computeDirectorySize(pkgDir); + entry.diskSpaceBytes += pkg.diskSizeBytes; + } + entry.packages.push_back(pkg); + } + + // Also check for direct PipeSamples folder if no installation packages were found + if (pkgMap.empty() && entry.exists) { + juce::File pipeSamples = organRoot.getChildFile("PipeSamples"); + if (!pipeSamples.isDirectory()) { + pipeSamples = entry.file.getParentDirectory().getChildFile("PipeSamples"); + } + if (pipeSamples.isDirectory()) { + const auto psSize = computeDirectorySize(pipeSamples); + entry.diskSpaceBytes += psSize; + } + } + + std::sort(entry.packages.begin(), entry.packages.end(), [](const OrganPackageInfo& a, const OrganPackageInfo& b) { + return a.packageId < b.packageId; + }); + + return entry; +} + +juce::File getOrganAudioStatCacheFile(const OrganEntry& entry) { + const auto cacheDir = MasterpieceProcessor::dataDirectory().getChildFile("cache"); + const juce::String statKey = entry.uniqueOrganId.isNotEmpty() + ? entry.uniqueOrganId + : juce::String::toHexString(entry.file.getFullPathName().hashCode64()); + return cacheDir.getChildFile(statKey + ".mpstats"); +} + +bool hasCachedOrganAudioStat(const OrganEntry& entry) { + const auto cacheFile = getOrganAudioStatCacheFile(entry); + if (!cacheFile.existsAsFile()) return false; + const auto lines = juce::StringArray::fromLines(cacheFile.loadFileAsString()); + return lines.size() >= 7 && lines[0].getLargeIntValue() > 0; +} + +OrganAudioStat computeOrganAudioStat( + const OrganEntry& entry, + const MasterpieceProcessor& proc, + std::function progressCallback, + std::atomic* cancelFlag) { + OrganAudioStat stat; + if (!entry.exists) return stat; + + const auto cacheFile = getOrganAudioStatCacheFile(entry); + if (cacheFile.existsAsFile()) { + const auto lines = juce::StringArray::fromLines(cacheFile.loadFileAsString()); + if (lines.size() >= 7) { + stat.totalAudioFrames = lines[0].getLargeIntValue(); + stat.attackFrames = lines[1].getLargeIntValue(); + stat.releaseFrames = lines[2].getLargeIntValue(); + stat.attackLoopFrames = lines[3].getLargeIntValue(); + stat.attackCount = lines[4].getIntValue(); + stat.releaseCount = lines[5].getIntValue(); + stat.rawPcmBytes = lines[6].getLargeIntValue(); + stat.hasStats = (stat.totalAudioFrames > 0); + if (stat.hasStats) { + if (progressCallback) progressCallback(1.0, 1, 1); + return stat; + } + } + } + + // Scan installed packages or sample directories for WAV files + std::vector sampleDirs; + for (const auto& pkg : entry.packages) { + if (pkg.isInstalled && pkg.directory.isDirectory()) { + sampleDirs.push_back(pkg.directory); + } + } + if (sampleDirs.empty()) { + juce::File pipeSamples = entry.organRootDir.getChildFile("PipeSamples"); + if (!pipeSamples.isDirectory()) { + pipeSamples = entry.file.getParentDirectory().getChildFile("PipeSamples"); + } + if (pipeSamples.isDirectory()) { + sampleDirs.push_back(pipeSamples); + } + } + + if (sampleDirs.empty()) { + return stat; + } + + juce::Array wavFiles; + for (const auto& sDir : sampleDirs) { + if (cancelFlag && cancelFlag->load()) return stat; + for (const auto& iter : juce::RangedDirectoryIterator(sDir, true, "*.wav", juce::File::findFiles)) { + if (cancelFlag && cancelFlag->load()) return stat; + wavFiles.add(iter.getFile()); + } + } + + const int totalFiles = wavFiles.size(); + if (totalFiles == 0) return stat; + + for (int fIdx = 0; fIdx < totalFiles; ++fIdx) { + if (cancelFlag && cancelFlag->load()) return stat; + + if (progressCallback && (fIdx % 25 == 0 || fIdx == totalFiles - 1)) { + const double p = static_cast(fIdx + 1) / static_cast(totalFiles); + progressCallback(p, fIdx + 1, totalFiles); + } + + const auto& f = wavFiles[fIdx]; + const auto pathStr = f.getFullPathName().toLowerCase(); + const bool isRelease = pathStr.contains("/r") || pathStr.contains("\\r"); + + std::unique_ptr stream(f.createInputStream()); + if (stream == nullptr || stream->getTotalLength() < 44) continue; + + char riffHdr[12]; + if (stream->read(riffHdr, 12) < 12) continue; + if (std::memcmp(riffHdr, "RIFF", 4) != 0 || std::memcmp(riffHdr + 8, "WAVE", 4) != 0) continue; + + uint16_t numChannels = 2; + uint16_t bitsPerSample = 24; + juce::int64 dataBytes = 0; + bool hasLoop = false; + uint32_t maxLoopEnd = 0; + + while (!stream->isExhausted()) { + char chunkHdr[8]; + if (stream->read(chunkHdr, 8) < 8) break; + const uint32_t chunkSize = juce::ByteOrder::littleEndianInt(chunkHdr + 4); + + if (std::memcmp(chunkHdr, "fmt ", 4) == 0) { + const int toRead = std::min(static_cast(chunkSize), 16); + char fmtBuf[16] = {0}; + if (stream->read(fmtBuf, toRead) < toRead) break; + numChannels = juce::ByteOrder::littleEndianShort(fmtBuf + 2); + bitsPerSample = juce::ByteOrder::littleEndianShort(fmtBuf + 14); + if (chunkSize > 16) stream->skipNextBytes(chunkSize - 16); + } else if (std::memcmp(chunkHdr, "data", 4) == 0) { + dataBytes = chunkSize; + stream->skipNextBytes(chunkSize); + } else if (std::memcmp(chunkHdr, "smpl", 4) == 0) { + if (chunkSize >= 36) { + juce::MemoryBlock smplData(chunkSize); + if (stream->read(smplData.getData(), static_cast(chunkSize)) == static_cast(chunkSize)) { + const char* p = static_cast(smplData.getData()); + const uint32_t numLoops = juce::ByteOrder::littleEndianInt(p + 28); + if (numLoops > 0 && chunkSize >= 36 + numLoops * 24) { + hasLoop = true; + for (uint32_t l = 0; l < numLoops; ++l) { + const uint32_t loopEnd = juce::ByteOrder::littleEndianInt(p + 36 + l * 24 + 12); + if (loopEnd > maxLoopEnd) maxLoopEnd = loopEnd; + } + } + } + } else { + stream->skipNextBytes(chunkSize); + } + } else { + stream->skipNextBytes(chunkSize); + } + + if (chunkSize % 2 != 0) { + stream->skipNextBytes(1); + } + } + + const int bytesPerFrame = (numChannels > 0 ? numChannels : 2) * (bitsPerSample > 0 ? ((bitsPerSample + 7) / 8) : 3); + const juce::int64 frames = bytesPerFrame > 0 ? (dataBytes / bytesPerFrame) : 0; + + stat.rawPcmBytes += dataBytes; + stat.totalAudioFrames += frames; + + if (isRelease) { + stat.releaseCount++; + stat.releaseFrames += frames; + } else { + stat.attackCount++; + stat.attackFrames += frames; + if (hasLoop && maxLoopEnd > 0) { + stat.attackLoopFrames += std::min(static_cast(maxLoopEnd), frames); + } else { + stat.attackLoopFrames += frames; + } + } + } + + stat.hasStats = (stat.totalAudioFrames > 0); + + if (stat.hasStats && (!cancelFlag || !cancelFlag->load())) { + cacheFile.getParentDirectory().createDirectory(); + juce::String out; + out << juce::String(stat.totalAudioFrames) << "\n" + << juce::String(stat.attackFrames) << "\n" + << juce::String(stat.releaseFrames) << "\n" + << juce::String(stat.attackLoopFrames) << "\n" + << juce::String(stat.attackCount) << "\n" + << juce::String(stat.releaseCount) << "\n" + << juce::String(stat.rawPcmBytes) << "\n"; + cacheFile.replaceWithText(out); + } + + if (progressCallback && (!cancelFlag || !cancelFlag->load())) { + progressCallback(1.0, totalFiles, totalFiles); + } + + return stat; +} + +void triggerBackgroundAudioStatPrecomputation(const juce::File& odfFile) { + if (!odfFile.existsAsFile()) return; + juce::Thread::launch([odfFile] { + MasterpieceProcessor dummyProc; + const auto entry = getOrganDetails(odfFile, dummyProc); + if (entry.exists && !hasCachedOrganAudioStat(entry)) { + computeOrganAudioStat(entry, dummyProc); + } + }); +} + +void triggerDirectoryAudioStatPrecomputation(const juce::File& dir) { + if (!dir.isDirectory()) return; + juce::Thread::launch([dir] { + MasterpieceProcessor dummyProc; + juce::Array odfs; + dir.findChildFiles(odfs, juce::File::findFiles, true, "*.Organ_Hauptwerk_xml"); + dir.findChildFiles(odfs, juce::File::findFiles, true, "*.CustomOrgan_Hauptwerk_xml"); + for (const auto& odf : odfs) { + const auto entry = getOrganDetails(odf, dummyProc); + if (entry.exists && !hasCachedOrganAudioStat(entry)) { + computeOrganAudioStat(entry, dummyProc); + } + } + }); +} + +juce::int64 estimateRamFootprintBytes(const OrganAudioStat& stat, const OrganAudioConfig& cfg) { + if (!stat.hasStats || stat.totalAudioFrames == 0) { + return 0; + } + + const int bytesPerSample = (cfg.storage == SampleStorage::Int16 ? 2 : (cfg.storage == SampleStorage::Float32 ? 4 : 3)); + const int channels = cfg.mono ? 1 : 2; + const int bytesPerFrame = bytesPerSample * channels; + + juce::int64 residentAttackFrames = stat.attackFrames; + if (cfg.preloadHeadFrames > 0) { + if (stat.attackCount > 0) { + residentAttackFrames = stat.attackLoopFrames + static_cast(stat.attackCount) * cfg.preloadHeadFrames; + residentAttackFrames = std::min(residentAttackFrames, stat.attackFrames); + } + } + + juce::int64 residentReleaseFrames = stat.releaseFrames; + if (cfg.streamReleases) { + const juce::int64 streamHead = (cfg.streamHeadFrames > 0 ? cfg.streamHeadFrames : 44100); + residentReleaseFrames = static_cast(stat.releaseCount) * streamHead; + residentReleaseFrames = std::min(residentReleaseFrames, stat.releaseFrames); + } + + const juce::int64 sampleRam = (residentAttackFrames + residentReleaseFrames) * bytesPerFrame; + constexpr juce::int64 engineOverheadBytes = 128 * 1024 * 1024; + return sampleRam + engineOverheadBytes; +} + +OrganAudioConfig loadOrganAudioConfig(const MasterpieceProcessor& proc, const juce::File& odf) { + OrganAudioConfig cfg; + auto readFromLines = [&](const juce::String& content) { + for (const auto& line : juce::StringArray::fromLines(content)) { + if (line.trim().isEmpty() || line.trimStart().startsWith("#")) continue; + const auto key = line.upToFirstOccurrenceOf(" ", false, false).trim(); + const auto val = line.fromFirstOccurrenceOf(" ", false, false).trim(); + const bool on = val.getIntValue() != 0; + if (key == "storage") { + const int s = val.getIntValue(); + cfg.storage = (s == 16 ? SampleStorage::Int16 : (s == 32 ? SampleStorage::Float32 : SampleStorage::Int24)); + } else if (key == "mono") { + cfg.mono = on; + } else if (key == "rate") { + cfg.sampleRate = val.getDoubleValue(); + } else if (key == "cache") { + const int c = val.getIntValue(); + cfg.cacheMode = (c == 0 ? SampleLibrary::CacheMode::Off : (c == 2 ? SampleLibrary::CacheMode::PerOrgan : SampleLibrary::CacheMode::Single)); + } else if (key == "stream") { + cfg.streamReleases = on; + } else if (key == "streamhead") { + cfg.streamHeadFrames = val.getLargeIntValue(); + } else if (key == "preload") { + cfg.preloadHeadFrames = val.getLargeIntValue(); + } else if (key == "root") { + cfg.organRootOverride = val.isEmpty() ? juce::File() : juce::File(val); + } else if (key == "simple") { + cfg.engineSwitch.simpleWavOnly = on; + } else if (key == "wind") { + cfg.engineSwitch.enableWindModel = on; + } else if (key == "tremulant") { + cfg.engineSwitch.enableTremulant = on; + } else if (key == "enclosure") { + cfg.engineSwitch.enableEnclosure = on; + } else if (key == "voicing") { + cfg.engineSwitch.enableVoicing = on; + } else if (key == "originalpitch") { + cfg.engineSwitch.playAtOriginalOrganPitch = on; + } + } + }; + + const auto gf = proc.globalSettingsFile(); + if (gf.existsAsFile()) { + readFromLines(gf.loadFileAsString()); + } + + if (odf.existsAsFile()) { + const auto of = proc.settingsFileFor(odf); + if (of.existsAsFile()) { + readFromLines(of.loadFileAsString()); + } + } + return cfg; +} + +bool saveOrganAudioConfig(MasterpieceProcessor& proc, const juce::File& odf, const OrganAudioConfig& cfg) { + if (odf.getFullPathName().isEmpty()) return false; + juce::File targetFile = proc.settingsFileFor(odf); + if (!targetFile.existsAsFile()) { + const auto dir = MasterpieceProcessor::dataDirectory().getChildFile("organs"); + dir.createDirectory(); + targetFile = dir.getChildFile(juce::String(MasterpieceProcessor::organKeyFor(odf)) + ".mporgan"); + } + + juce::StringArray preservedLines; + if (targetFile.existsAsFile()) { + for (const auto& line : juce::StringArray::fromLines(targetFile.loadFileAsString())) { + if (line.trim().isEmpty() || line.trimStart().startsWith("#")) continue; + const auto key = line.upToFirstOccurrenceOf(" ", false, false).trim(); + if (key == "storage" || key == "mono" || key == "rate" || key == "cache" || + key == "stream" || key == "streamhead" || key == "preload" || key == "root" || + key == "simple" || key == "wind" || key == "tremulant" || key == "enclosure" || + key == "voicing" || key == "originalpitch") + continue; + preservedLines.add(line); + } + } + + targetFile.getParentDirectory().createDirectory(); + + juce::String text = "# Masterpiece per-organ settings\n"; + text << "storage " << (cfg.storage == SampleStorage::Int16 ? 16 : (cfg.storage == SampleStorage::Int24 ? 24 : 32)) << "\n"; + text << "mono " << (cfg.mono ? 1 : 0) << "\n"; + text << "rate " << juce::String(cfg.sampleRate, 0) << "\n"; + text << "cache " << static_cast(cfg.cacheMode) << "\n"; + text << "stream " << (cfg.streamReleases ? 1 : 0) << "\n"; + text << "streamhead " << juce::String(cfg.streamHeadFrames) << "\n"; + text << "preload " << juce::String(cfg.preloadHeadFrames) << "\n"; + if (cfg.organRootOverride.getFullPathName().isNotEmpty()) + text << "root " << cfg.organRootOverride.getFullPathName() << "\n"; + text << "simple " << (cfg.engineSwitch.simpleWavOnly ? 1 : 0) << "\n"; + text << "wind " << (cfg.engineSwitch.enableWindModel ? 1 : 0) << "\n"; + text << "tremulant " << (cfg.engineSwitch.enableTremulant ? 1 : 0) << "\n"; + text << "enclosure " << (cfg.engineSwitch.enableEnclosure ? 1 : 0) << "\n"; + text << "voicing " << (cfg.engineSwitch.enableVoicing ? 1 : 0) << "\n"; + text << "originalpitch " << (cfg.engineSwitch.playAtOriginalOrganPitch ? 1 : 0) << "\n"; + + for (const auto& line : preservedLines) { + text << line << "\n"; + } + + bool ok = targetFile.replaceWithText(text); + + if (proc.loadedOdf() == odf) { + proc.setSampleStorage(cfg.storage); + proc.setLoadMono(cfg.mono); + proc.setLoadSampleRate(cfg.sampleRate); + proc.setCacheMode(cfg.cacheMode); + proc.setStreamReleases(cfg.streamReleases); + proc.setStreamHeadFrames(cfg.streamHeadFrames); + proc.setPreloadHeadFrames(cfg.preloadHeadFrames); + proc.setOrganRootOverride(cfg.organRootOverride); + proc.setEngineSwitch(cfg.engineSwitch); + proc.markSettingsDirty(); + } + + return ok; +} + +juce::File findUnrarBinary() { + const juce::String binName = +#if JUCE_WINDOWS + "unrar.exe"; +#else + "unrar"; +#endif + + const auto appExe = juce::File::getSpecialLocation(juce::File::currentExecutableFile); + const auto sibling = appExe.getSiblingFile(binName); + if (sibling.existsAsFile()) return sibling; + +#if JUCE_MAC + const auto macOsDir = appExe.getParentDirectory(); + const auto bundleMacOS = macOsDir.getChildFile(binName); + if (bundleMacOS.existsAsFile()) return bundleMacOS; + const auto bundleResources = macOsDir.getSiblingFile("Resources").getChildFile(binName); + if (bundleResources.existsAsFile()) return bundleResources; +#endif + + const auto dataDirBin = MasterpieceProcessor::dataDirectory().getChildFile(binName); + if (dataDirBin.existsAsFile()) return dataDirBin; + +#if !JUCE_WINDOWS + for (const char* path : {"/opt/homebrew/bin/unrar", + "/usr/local/bin/unrar", + "/usr/bin/unrar", + "/bin/unrar"}) { + juce::File f(path); + if (f.existsAsFile()) return f; + } +#endif + + const auto envPath = juce::SystemStats::getEnvironmentVariable("PATH", {}); +#if JUCE_WINDOWS + const auto sep = ";"; +#else + const auto sep = ":"; +#endif + auto tokens = juce::StringArray::fromTokens(envPath, sep, "\""); + for (const auto& dirStr : tokens) { + const auto dir = juce::File(dirStr.trim()); + const auto candidate = dir.getChildFile(binName); + if (candidate.existsAsFile()) return candidate; + } + + return {}; +} + +static bool isSecondaryArchivePart(const juce::String& filename) { + const auto lower = filename.toLowerCase(); + + int partIdx = lower.lastIndexOf(".part"); + if (partIdx >= 0) { + int rarIdx = lower.lastIndexOf(".rar"); + if (rarIdx > partIdx + 5) { + juce::String numStr = lower.substring(partIdx + 5, rarIdx); + int partNum = numStr.getIntValue(); + if (partNum > 1) return true; + } + } + + int extDot = lower.lastIndexOfChar('.'); + if (extDot >= 0 && extDot + 3 < lower.length()) { + if (lower[extDot + 1] == 'r' && + juce::CharacterFunctions::isDigit(lower[extDot + 2]) && + juce::CharacterFunctions::isDigit(lower[extDot + 3])) { + return true; + } + } + + int compPartIdx = lower.lastIndexOf("part"); + if (compPartIdx >= 0 && lower.contains("comppkg_hauptwerk_rar")) { + juce::String tail = lower.substring(compPartIdx + 4); + int dotAfter = tail.indexOfChar('.'); + juce::String numStr = (dotAfter >= 0 ? tail.substring(0, dotAfter) : tail); + int partNum = numStr.getIntValue(); + if (partNum > 1) return true; + } + + return false; +} + +static juce::File findPrimaryVolume(const juce::File& file) { + const auto dir = file.getParentDirectory(); + const auto name = file.getFileName(); + const auto lower = name.toLowerCase(); + + int partIdx = lower.lastIndexOf(".part"); + if (partIdx >= 0) { + juce::String prefix = name.substring(0, partIdx); + for (const char* p1 : {".part1.rar", ".part01.rar", ".part001.rar", ".part1.RAR", ".part01.RAR"}) { + auto f = dir.getChildFile(prefix + p1); + if (f.existsAsFile()) return f; + } + } + + int extDot = lower.lastIndexOfChar('.'); + if (extDot >= 0 && extDot + 3 < lower.length() && lower[extDot + 1] == 'r' && + juce::CharacterFunctions::isDigit(lower[extDot + 2])) { + juce::String base = name.substring(0, extDot); + for (const char* ext : {".rar", ".RAR"}) { + auto f = dir.getChildFile(base + ext); + if (f.existsAsFile()) return f; + } + } + + return file; +} + +juce::Array filterArchivesForExtraction(const juce::Array& files) { + juce::Array result; + for (const auto& file : files) { + if (!file.existsAsFile()) continue; + + if (isSecondaryArchivePart(file.getFileName())) { + const auto primary = findPrimaryVolume(file); + if (primary.existsAsFile() && !result.contains(primary)) { + result.add(primary); + } + } else { + if (!result.contains(file)) { + result.add(file); + } + } + } + return result; +} + +juce::String readOrganNameFromOdf(const juce::File& file) { + if (!file.existsAsFile()) return file.getFileNameWithoutExtension(); + + std::unique_ptr stream(file.createInputStream()); + if (stream == nullptr) return file.getFileNameWithoutExtension(); + + const int bytesToRead = std::min(static_cast(stream->getTotalLength()), 65536); + juce::MemoryBlock block(static_cast(bytesToRead)); + stream->read(block.getData(), bytesToRead); + + pugi::xml_document doc; + pugi::xml_parse_result res = doc.load_buffer(block.getData(), block.getSize(), + pugi::parse_default, pugi::encoding_utf8); + + if (!res) { + pugi::xml_document docLatin; + res = docLatin.load_buffer(block.getData(), block.getSize(), + pugi::parse_default, pugi::encoding_latin1); + if (res) { + doc.reset(docLatin); + } + } + + if (res) { + for (pugi::xml_node general : doc.child("Hauptwerk").children("ObjectList")) { + if (std::string(general.attribute("ObjectType").value()) == "_General") { + for (pugi::xml_node g : general.children("_General")) { + pugi::xml_node nameNode = g.child("Identification_Name"); + if (nameNode && nameNode.text().as_string()[0] != '\0') { + return juce::String::fromUTF8(nameNode.text().as_string()).trim(); + } + pugi::xml_node organNameNode = g.child("Identification_OrganName"); + if (organNameNode && organNameNode.text().as_string()[0] != '\0') { + return juce::String::fromUTF8(organNameNode.text().as_string()).trim(); + } + } + } + } + } + + return file.getFileNameWithoutExtension(); +} + +std::vector discoverOrgans(const MasterpieceProcessor& proc) { + std::vector result; + std::unordered_set seenPaths; + + auto addFile = [&](const juce::File& f, bool isFromDataDir) { + const auto p = f.getFullPathName().toStdString(); + if (seenPaths.count(p) > 0) return; + if (proc.isOrganHidden(f)) return; + + // Filter out missing phantom/placeholder "Organ1" entries + if (!f.existsAsFile()) { + const auto fname = f.getFileName(); + const auto fstem = f.getFileNameWithoutExtension(); + if (fname == "Organ1" || fstem == "Organ1" || fname.startsWithIgnoreCase("Organ1.") || + f.getFullPathName() == "Organ1") { + return; + } + } + + seenPaths.insert(p); + + OrganEntry entry = getOrganDetails(f, proc); + entry.isInstalled = isFromDataDir; + result.push_back(std::move(entry)); + }; + + const auto dataDir = MasterpieceProcessor::dataDirectory(); + const auto defsDir = dataDir.getChildFile("OrganDefinitions"); + + auto scanDir = [&](const juce::File& d) { + if (!d.isDirectory()) return; + juce::Array found; + d.findChildFiles(found, juce::File::findFiles, true, "*.Organ_Hauptwerk_xml"); + for (const auto& f : found) addFile(f, true); + found.clear(); + d.findChildFiles(found, juce::File::findFiles, true, "*.CustomOrgan_Hauptwerk_xml"); + for (const auto& f : found) addFile(f, true); + }; + + scanDir(defsDir); + if (defsDir != dataDir) { + scanDir(dataDir); + } + + for (const auto& f : proc.recentOrgans()) { + addFile(f, f.isAChildOf(dataDir)); + } + + if (proc.loadedOdf().existsAsFile()) { + addFile(proc.loadedOdf(), proc.loadedOdf().isAChildOf(dataDir)); + } + + std::sort(result.begin(), result.end(), [](const OrganEntry& a, const OrganEntry& b) { + return a.name.compareIgnoreCase(b.name) < 0; + }); + + return result; +} + +// ============================================================================== +// OrganDetailsDialog implementation +// ============================================================================== +OrganDetailsDialog::OrganDetailsDialog(const OrganEntry& entry, MasterpieceProcessor& proc, + std::function onOpenAudioSettings) + : entry_(entry), proc_(proc), onOpenAudioSettings_(std::move(onOpenAudioSettings)) { + setSize(660, 520); + + titleLabel_.setText(entry_.name, juce::dontSendNotification); + titleLabel_.setFont(juce::FontOptions(18.0f, juce::Font::bold)); + titleLabel_.setColour(juce::Label::textColourId, juce::Colour(0xffe8edf5)); + addAndMakeVisible(titleLabel_); + + pathLabel_.setText("File: " + entry_.file.getFullPathName() + + (entry_.odfSizeBytes > 0 ? " (" + formatByteSize(entry_.odfSizeBytes) + ")" : ""), + juce::dontSendNotification); + pathLabel_.setFont(juce::FontOptions(11.0f)); + pathLabel_.setColour(juce::Label::textColourId, juce::Colour(0xff8a95a5)); + addAndMakeVisible(pathLabel_); + + juce::String rootStr = entry_.organRootDir.getFullPathName(); + if (rootStr.isEmpty()) rootStr = "(Default)"; + rootLabel_.setText("Root: " + rootStr, juce::dontSendNotification); + rootLabel_.setFont(juce::FontOptions(11.0f)); + rootLabel_.setColour(juce::Label::textColourId, juce::Colour(0xff8a95a5)); + addAndMakeVisible(rootLabel_); + + juce::String sizeStr = "Total Disk Space: " + formatByteSize(entry_.diskSpaceBytes); + if (!entry_.packages.empty()) { + juce::int64 pkgBytes = entry_.diskSpaceBytes - entry_.odfSizeBytes; + sizeStr += " (" + formatByteSize(pkgBytes) + " across " + + juce::String(entry_.packages.size()) + " package" + + (entry_.packages.size() == 1 ? "" : "s") + " + " + + formatByteSize(entry_.odfSizeBytes) + " definition)"; + } + sizeLabel_.setText(sizeStr, juce::dontSendNotification); + sizeLabel_.setFont(juce::FontOptions(12.0f, juce::Font::bold)); + sizeLabel_.setColour(juce::Label::textColourId, juce::Colour(0xff98e2b0)); + addAndMakeVisible(sizeLabel_); + + OrganAudioConfig cfg = loadOrganAudioConfig(proc_, entry_.file); + juce::String audioSummary = "Configured: "; + audioSummary += (cfg.storage == SampleStorage::Int16 ? "16-bit" : (cfg.storage == SampleStorage::Float32 ? "32-bit" : "24-bit")); + audioSummary += cfg.mono ? ", Mono" : ", Stereo"; + audioSummary += cfg.streamReleases ? ", Stream Releases" : ", Hold in RAM"; + settingsLabel_.setText(audioSummary, juce::dontSendNotification); + settingsLabel_.setFont(juce::FontOptions(11.0f)); + settingsLabel_.setColour(juce::Label::textColourId, juce::Colour(0xffc2d4ea)); + addAndMakeVisible(settingsLabel_); + + packagesHeaderLabel_.setText("Associated Packages (" + juce::String(entry_.packages.size()) + "):", + juce::dontSendNotification); + packagesHeaderLabel_.setFont(juce::FontOptions(13.0f, juce::Font::bold)); + packagesHeaderLabel_.setColour(juce::Label::textColourId, juce::Colour(0xffe8edf5)); + addAndMakeVisible(packagesHeaderLabel_); + + packageList_.setModel(this); + packageList_.setRowHeight(40); + packageList_.setColour(juce::ListBox::backgroundColourId, juce::Colour(0xff121418)); + packageList_.setColour(juce::ListBox::outlineColourId, juce::Colour(0xff232833)); + addAndMakeVisible(packageList_); + + adjustAudioBtn_.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff2a3442)); + adjustAudioBtn_.setColour(juce::TextButton::textColourOffId, juce::Colour(0xffc2d4ea)); + adjustAudioBtn_.onClick = [this] { + if (onOpenAudioSettings_) { + onOpenAudioSettings_(); + } + if (auto* dw = findParentComponentOfClass()) + dw->exitModalState(0); + }; + addAndMakeVisible(adjustAudioBtn_); + + revealBtn_.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff232833)); + revealBtn_.setColour(juce::TextButton::textColourOffId, juce::Colour(0xffc2c8d2)); + revealBtn_.onClick = [this] { + if (entry_.file.existsAsFile()) { + entry_.file.revealToUser(); + } else if (entry_.organRootDir.isDirectory()) { + entry_.organRootDir.revealToUser(); + } + }; + addAndMakeVisible(revealBtn_); + + closeBtn_.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff232833)); + closeBtn_.setColour(juce::TextButton::textColourOffId, juce::Colour(0xffc2c8d2)); + closeBtn_.onClick = [this] { + if (auto* dw = findParentComponentOfClass()) + dw->exitModalState(0); + }; + addAndMakeVisible(closeBtn_); +} + +void OrganDetailsDialog::resized() { + const int pad = 16; + const int w = getWidth() - pad * 2; + int y = pad; + + titleLabel_.setBounds(pad, y, w, 24); + y += 26; + pathLabel_.setBounds(pad, y, w, 18); + y += 20; + rootLabel_.setBounds(pad, y, w, 18); + y += 22; + sizeLabel_.setBounds(pad, y, w, 20); + y += 22; + settingsLabel_.setBounds(pad, y, w, 18); + y += 24; + + packagesHeaderLabel_.setBounds(pad, y, w, 20); + y += 24; + + const int bottomH = 32; + const int bottomY = getHeight() - pad - bottomH; + + packageList_.setBounds(pad, y, w, bottomY - y - 12); + + adjustAudioBtn_.setBounds(pad, bottomY, 180, bottomH); + revealBtn_.setBounds(pad + 190, bottomY, 140, bottomH); + closeBtn_.setBounds(getWidth() - pad - 80, bottomY, 80, bottomH); +} + +void OrganDetailsDialog::paint(juce::Graphics& g) { + g.fillAll(juce::Colour(0xff1b1e24)); +} + +int OrganDetailsDialog::getNumRows() { + return static_cast(entry_.packages.size()); +} + +void OrganDetailsDialog::paintListBoxItem(int rowNumber, juce::Graphics& g, int width, int height, + bool rowIsSelected) { + if (rowNumber < 0 || rowNumber >= static_cast(entry_.packages.size())) return; + + const auto& pkg = entry_.packages[rowNumber]; + + if (rowIsSelected) { + g.setColour(juce::Colour(0xff23354d)); + g.fillRect(0, 0, width, height); + } else if (rowNumber % 2 == 1) { + g.setColour(juce::Colour(0xff16181f)); + g.fillRect(0, 0, width, height); + } + + const int leftMargin = 12; + const int rightMargin = 90; + const int contentWidth = width - leftMargin - rightMargin; + + g.setFont(juce::FontOptions(13.0f, juce::Font::bold)); + g.setColour(pkg.isInstalled ? juce::Colour(0xffe8edf5) : juce::Colour(0xffa0abbd)); + juce::String pkgTitle = juce::String::formatted("Package %06d", pkg.packageId); + if (pkg.name.isNotEmpty()) { + pkgTitle += " - " + pkg.name; + } + g.drawText(pkgTitle, leftMargin, 3, contentWidth, 18, juce::Justification::left, true); + + g.setFont(juce::FontOptions(11.0f)); + g.setColour(juce::Colour(0xff758092)); + juce::String sub; + if (pkg.supplierName.isNotEmpty()) { + sub += "Supplier: " + pkg.supplierName + " \u2022 "; + } + if (pkg.isInstalled) { + sub += formatByteSize(pkg.diskSizeBytes); + } else { + sub += "Not found on disk"; + } + g.drawText(sub, leftMargin, 21, contentWidth, 16, juce::Justification::left, true); + + auto badgeRect = juce::Rectangle(width - 86, (height - 20) / 2, 76, 20); + if (pkg.isInstalled) { + g.setColour(juce::Colour(0xff1b3d2b)); + g.fillRoundedRectangle(badgeRect.toFloat(), 4.0f); + g.setColour(juce::Colour(0xff68d391)); + g.setFont(juce::FontOptions(11.0f)); + g.drawText("Installed", badgeRect, juce::Justification::centred, false); + } else { + g.setColour(juce::Colour(0xff3d1f1f)); + g.fillRoundedRectangle(badgeRect.toFloat(), 4.0f); + g.setColour(juce::Colour(0xffe28080)); + g.setFont(juce::FontOptions(11.0f)); + g.drawText("Missing", badgeRect, juce::Justification::centred, false); + } + + g.setColour(juce::Colour(0xff20242c)); + g.fillRect(0, height - 1, width, 1); +} + +// ============================================================================== +// RamGraphMeterComponent implementation +// ============================================================================== +RamGraphMeterComponent::RamGraphMeterComponent() {} + +void RamGraphMeterComponent::setValues(juce::int64 totalSystemRamBytes, + juce::int64 osUsedRamBytes, + juce::int64 organFootprintBytes) { + totalSystemRam_ = totalSystemRamBytes; + osUsedRam_ = osUsedRamBytes; + organFootprint_ = organFootprintBytes; + repaint(); +} + +void RamGraphMeterComponent::paint(juce::Graphics& g) { + const auto bounds = getLocalBounds().toFloat(); + const float w = bounds.getWidth(); + const float h = bounds.getHeight(); + + // Background bar track + g.setColour(juce::Colour(0xff12151b)); + g.fillRoundedRectangle(0, 0, w, h, 6.0f); + + if (totalSystemRam_ <= 0) return; + + const double total = static_cast(totalSystemRam_); + const double osFraction = juce::jlimit(0.0, 1.0, static_cast(osUsedRam_) / total); + const double organFraction = juce::jlimit(0.0, 1.0 - osFraction, static_cast(organFootprint_) / total); + const double totalUsedFraction = osFraction + organFraction; + + const float osW = static_cast(w * osFraction); + const float organW = static_cast(w * organFraction); + + // 1. Draw OS reservation / baseline + if (osW > 1.0f) { + g.setColour(juce::Colour(0xff333e50)); // Slate blue-gray + g.fillRoundedRectangle(0, 0, osW + 4.0f, h, 6.0f); + g.fillRect(osW - 4.0f, 0.0f, 4.0f, h); + } + + // 2. Draw Organ Footprint segment with color thresholding + if (organW > 1.0f) { + juce::Colour organCol; + if (totalUsedFraction < 0.60) { + organCol = juce::Colour(0xff38a169); // Green / Safe + } else if (totalUsedFraction < 0.75) { + organCol = juce::Colour(0xffd69e2e); // Yellow / Moderate + } else if (totalUsedFraction < 0.88) { + organCol = juce::Colour(0xffdd6b20); // Orange / High + } else { + organCol = juce::Colour(0xffe53e3e); // Red / Danger + } + + g.setColour(organCol); + if (totalUsedFraction >= 0.98f) { + g.fillRoundedRectangle(osW, 0, organW, h, 6.0f); + } else { + g.fillRect(osW, 0.0f, organW, h); + } + } + + // Border outline + g.setColour(juce::Colour(0xff2f3a4d)); + g.drawRoundedRectangle(0.5f, 0.5f, w - 1.0f, h - 1.0f, 6.0f, 1.0f); + + // Threshold tick marks at 75% and 88% + const float x75 = w * 0.75f; + const float x88 = w * 0.88f; + g.setColour(juce::Colour(0x60ffffff)); + g.drawVerticalLine(static_cast(x75), 0.0f, h); + g.drawVerticalLine(static_cast(x88), 0.0f, h); +} + +// ============================================================================== +// StatScanThread implementation +// ============================================================================== +class OrganAudioSettingsDialog::StatScanThread : public juce::Thread { +public: + StatScanThread(OrganAudioSettingsDialog& owner, const OrganEntry& entry, const MasterpieceProcessor& proc) + : juce::Thread("OrganStatScanner"), owner_(&owner), entry_(entry), proc_(proc) {} + + ~StatScanThread() override { + cancel(); + stopThread(3000); + } + + void cancel() { + cancelFlag_.store(true); + signalThreadShouldExit(); + } + + void run() override { + OrganAudioStat stat = computeOrganAudioStat( + entry_, proc_, + [this](double progress, int current, int total) { + if (cancelFlag_.load() || threadShouldExit()) return; + juce::MessageManager::callAsync([owner = owner_, progress, current, total] { + if (owner != nullptr) { + owner->onScanProgress(progress, current, total); + } + }); + }, + &cancelFlag_); + + if (!cancelFlag_.load() && !threadShouldExit()) { + juce::MessageManager::callAsync([owner = owner_, stat] { + if (owner != nullptr) { + owner->onScanCompleted(stat); + } + }); + } + } + +private: + juce::Component::SafePointer owner_; + OrganEntry entry_; + const MasterpieceProcessor& proc_; + std::atomic cancelFlag_{false}; +}; + +// ============================================================================== +// OrganAudioSettingsDialog implementation +// ============================================================================== +OrganAudioSettingsDialog::OrganAudioSettingsDialog(const OrganEntry& entry, MasterpieceProcessor& proc) + : entry_(entry), proc_(proc) { + setSize(640, 660); + config_ = loadOrganAudioConfig(proc_, entry_.file); + + titleLabel_.setText(entry_.name + " - Audio Settings", juce::dontSendNotification); + titleLabel_.setFont(juce::FontOptions(16.0f, juce::Font::bold)); + titleLabel_.setColour(juce::Label::textColourId, juce::Colour(0xffe8edf5)); + addAndMakeVisible(titleLabel_); + + subtitleLabel_.setText("Configure sample loading, bit depth, streaming, and DSP switches without loading into RAM.", + juce::dontSendNotification); + subtitleLabel_.setFont(juce::FontOptions(11.0f)); + subtitleLabel_.setColour(juce::Label::textColourId, juce::Colour(0xff8a95a5)); + addAndMakeVisible(subtitleLabel_); + + // RAM Usage Graph & progress + ramHeading_.setText("RAM Consumption & System Impact", juce::dontSendNotification); + ramHeading_.setFont(juce::FontOptions(12.0f, juce::Font::bold)); + ramHeading_.setColour(juce::Label::textColourId, juce::Colour(0xffc2c8d2)); + addAndMakeVisible(ramHeading_); + + scanProgressBar_.setColour(juce::ProgressBar::foregroundColourId, juce::Colour(0xff4a90e2)); + scanProgressBar_.setColour(juce::ProgressBar::backgroundColourId, juce::Colour(0xff12151b)); + addChildComponent(scanProgressBar_); + + scanStatusLabel_.setFont(juce::FontOptions(11.0f)); + scanStatusLabel_.setColour(juce::Label::textColourId, juce::Colour(0xff68b2ff)); + addChildComponent(scanStatusLabel_); + + addAndMakeVisible(ramMeter_); + + ramDetailsLabel_.setFont(juce::FontOptions(11.0f)); + ramDetailsLabel_.setColour(juce::Label::textColourId, juce::Colour(0xff98a9c2)); + addAndMakeVisible(ramDetailsLabel_); + + profileLabel_.setText("Memory profile", juce::dontSendNotification); + profileLabel_.setFont(juce::FontOptions(12.0f, juce::Font::bold)); + profileLabel_.setColour(juce::Label::textColourId, juce::Colour(0xffc2c8d2)); + addAndMakeVisible(profileLabel_); + + profileCombo_.onChange = [this] { + const int id = profileCombo_.getSelectedId(); + if (id == 1) { + config_.storage = SampleStorage::Int24; + config_.streamReleases = false; + config_.mono = false; + config_.preloadHeadFrames = 0; + updateControlsFromConfig(); + } else if (id == 2) { + config_.storage = SampleStorage::Int16; + config_.streamReleases = true; + config_.mono = false; + config_.preloadHeadFrames = 0; + updateControlsFromConfig(); + } else if (id == 3) { + config_.storage = SampleStorage::Int16; + config_.streamReleases = true; + config_.mono = true; + config_.preloadHeadFrames = 0; + updateControlsFromConfig(); + } + updateRamFootprintDisplay(); + }; + addAndMakeVisible(profileCombo_); + + storageLabel_.setText("Resident sample format", juce::dontSendNotification); + storageLabel_.setFont(juce::FontOptions(12.0f)); + storageLabel_.setColour(juce::Label::textColourId, juce::Colour(0xffa0abbd)); + addAndMakeVisible(storageLabel_); + + storageCombo_.onChange = [this] { + const int id = storageCombo_.getSelectedId(); + config_.storage = (id == 2 ? SampleStorage::Int16 : (id == 3 ? SampleStorage::Float32 : SampleStorage::Int24)); + syncProfile(); + updateRamFootprintDisplay(); + }; + addAndMakeVisible(storageCombo_); + + rebuildDropdownItemTexts(); + + monoToggle_.setColour(juce::ToggleButton::textColourId, juce::Colour(0xffe8edf5)); + monoToggle_.onClick = [this] { + config_.mono = monoToggle_.getToggleState(); + syncProfile(); + updateRamFootprintDisplay(); + }; + addAndMakeVisible(monoToggle_); + + rateLabel_.setText("Sample rate", juce::dontSendNotification); + rateLabel_.setFont(juce::FontOptions(12.0f)); + rateLabel_.setColour(juce::Label::textColourId, juce::Colour(0xffa0abbd)); + addAndMakeVisible(rateLabel_); + + rateCombo_.addItem("As recorded", 1); + rateCombo_.addItem("48 kHz", 2); + rateCombo_.addItem("44.1 kHz", 3); + rateCombo_.onChange = [this] { + const int id = rateCombo_.getSelectedId(); + config_.sampleRate = (id == 2 ? 48000.0 : (id == 3 ? 44100.0 : 0.0)); + }; + addAndMakeVisible(rateCombo_); + + streamToggle_.setColour(juce::ToggleButton::textColourId, juce::Colour(0xffe8edf5)); + streamToggle_.onClick = [this] { + config_.streamReleases = streamToggle_.getToggleState(); + syncProfile(); + updateRamFootprintDisplay(); + }; + addAndMakeVisible(streamToggle_); + + preloadLabel_.setText("Preloaded per sample", juce::dontSendNotification); + preloadLabel_.setFont(juce::FontOptions(12.0f)); + preloadLabel_.setColour(juce::Label::textColourId, juce::Colour(0xffa0abbd)); + addAndMakeVisible(preloadLabel_); + + preloadCombo_.addItem("Whole samples (hold all in RAM)", 1); + preloadCombo_.addItem("Loop + 2 s", 2); + preloadCombo_.addItem("Loop + 1 s", 3); + preloadCombo_.addItem("Loop only (minimal RAM, stream remainder)", 4); + preloadCombo_.onChange = [this] { + const int id = preloadCombo_.getSelectedId(); + config_.preloadHeadFrames = (id == 1 ? 0 : (id == 2 ? 88200 : (id == 3 ? 44100 : 1))); + syncProfile(); + updateRamFootprintDisplay(); + }; + addAndMakeVisible(preloadCombo_); + + cacheLabel_.setText("Sample cache", juce::dontSendNotification); + cacheLabel_.setFont(juce::FontOptions(12.0f)); + cacheLabel_.setColour(juce::Label::textColourId, juce::Colour(0xffa0abbd)); + addAndMakeVisible(cacheLabel_); + + cacheCombo_.addItem("One cache, replaced as organs change", 1); + cacheCombo_.addItem("One cache per organ (uses more disk)", 2); + cacheCombo_.addItem("Off", 3); + cacheCombo_.onChange = [this] { + const int id = cacheCombo_.getSelectedId(); + config_.cacheMode = (id == 3 ? SampleLibrary::CacheMode::Off : (id == 2 ? SampleLibrary::CacheMode::PerOrgan : SampleLibrary::CacheMode::Single)); + }; + addAndMakeVisible(cacheCombo_); + + rootLabel_.setText("Organ folder (OrganInstallationPackages)", juce::dontSendNotification); + rootLabel_.setFont(juce::FontOptions(12.0f)); + rootLabel_.setColour(juce::Label::textColourId, juce::Colour(0xffa0abbd)); + addAndMakeVisible(rootLabel_); + + rootValue_.setFont(juce::FontOptions(11.0f)); + rootValue_.setColour(juce::Label::textColourId, juce::Colours::lightgrey); + addAndMakeVisible(rootValue_); + + rootChooseBtn_.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff232833)); + rootChooseBtn_.setColour(juce::TextButton::textColourOffId, juce::Colour(0xffc2c8d2)); + rootChooseBtn_.onClick = [this] { + rootChooser_ = std::make_unique( + "Which folder holds OrganInstallationPackages?", + config_.organRootOverride.exists() ? config_.organRootOverride : juce::File(proc_.organRootDir())); + rootChooser_->launchAsync( + juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectDirectories, + [this](const juce::FileChooser& fc) { + const auto dir = fc.getResult(); + if (dir.getFullPathName().isEmpty()) return; + config_.organRootOverride = dir; + showOrganRoot(); + }); + }; + addAndMakeVisible(rootChooseBtn_); + + rootDefaultBtn_.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff232833)); + rootDefaultBtn_.setColour(juce::TextButton::textColourOffId, juce::Colour(0xff8a95a5)); + rootDefaultBtn_.onClick = [this] { + config_.organRootOverride = juce::File(); + showOrganRoot(); + }; + addAndMakeVisible(rootDefaultBtn_); + + dspHeading_.setText("Engine & DSP switches", juce::dontSendNotification); + dspHeading_.setFont(juce::FontOptions(12.0f, juce::Font::bold)); + dspHeading_.setColour(juce::Label::textColourId, juce::Colour(0xffc2c8d2)); + addAndMakeVisible(dspHeading_); + + for (auto* tb : {&simpleWav_, &wind_, &tremulant_, &enclosure_, &voicing_, &originalPitch_}) { + tb->setColour(juce::ToggleButton::textColourId, juce::Colour(0xffe8edf5)); + addAndMakeVisible(*tb); + } + + simpleWav_.onClick = [this] { config_.engineSwitch.simpleWavOnly = simpleWav_.getToggleState(); }; + wind_.onClick = [this] { config_.engineSwitch.enableWindModel = wind_.getToggleState(); }; + tremulant_.onClick = [this] { config_.engineSwitch.enableTremulant = tremulant_.getToggleState(); }; + enclosure_.onClick = [this] { config_.engineSwitch.enableEnclosure = enclosure_.getToggleState(); }; + voicing_.onClick = [this] { config_.engineSwitch.enableVoicing = voicing_.getToggleState(); }; + originalPitch_.onClick = [this] { config_.engineSwitch.playAtOriginalOrganPitch = originalPitch_.getToggleState(); }; + + saveBtn_.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff2d4a6e)); + saveBtn_.setColour(juce::TextButton::textColourOffId, juce::Colour(0xffe8edf5)); + saveBtn_.onClick = [this] { save(); }; + addAndMakeVisible(saveBtn_); + + defaultsBtn_.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff232833)); + defaultsBtn_.setColour(juce::TextButton::textColourOffId, juce::Colour(0xff8a95a5)); + defaultsBtn_.onClick = [this] { resetToDefaults(); }; + addAndMakeVisible(defaultsBtn_); + + cancelBtn_.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff232833)); + cancelBtn_.setColour(juce::TextButton::textColourOffId, juce::Colour(0xffc2c8d2)); + cancelBtn_.onClick = [this] { closeDialog(); }; + addAndMakeVisible(cancelBtn_); + + updateControlsFromConfig(); + + if (hasCachedOrganAudioStat(entry_)) { + audioStat_ = computeOrganAudioStat(entry_, proc_); + isScanning_ = false; + rebuildDropdownItemTexts(); + + const juce::File sf = proc_.settingsFileFor(entry_.file); + if (!sf.existsAsFile()) { + const juce::int64 sysRamBytes = static_cast(juce::SystemStats::getMemorySizeInMegabytes()) * 1024 * 1024; + const juce::int64 osReservation = std::max(static_cast(4LL * 1024 * 1024 * 1024), + static_cast(sysRamBytes * 0.20)); + const juce::int64 availableForOrgan = std::max(0, sysRamBytes - osReservation); + + OrganAudioConfig q24; + q24.storage = SampleStorage::Int24; + q24.streamReleases = false; + q24.mono = false; + + OrganAudioConfig r16; + r16.storage = SampleStorage::Int16; + r16.streamReleases = true; + r16.mono = false; + + const juce::int64 ram24 = estimateRamFootprintBytes(audioStat_, q24); + const juce::int64 ram16 = estimateRamFootprintBytes(audioStat_, r16); + + if (ram24 > 0 && ram24 <= availableForOrgan) { + config_.storage = SampleStorage::Int24; + config_.streamReleases = false; + config_.mono = false; + } else if (ram16 > 0 && ram16 <= availableForOrgan) { + config_.storage = SampleStorage::Int16; + config_.streamReleases = true; + config_.mono = false; + } else if (ram16 > 0) { + config_.storage = SampleStorage::Int16; + config_.streamReleases = true; + config_.mono = true; + } + updateControlsFromConfig(); + } + updateRamFootprintDisplay(); + } else { + isScanning_ = true; + ramMeter_.setVisible(false); + ramDetailsLabel_.setVisible(false); + scanProgressBar_.setVisible(true); + scanStatusLabel_.setVisible(true); + scanStatusLabel_.setText("Calculating RAM footprint: scanning samples...", juce::dontSendNotification); + + statThread_ = std::make_unique(*this, entry_, proc_); + statThread_->startThread(juce::Thread::Priority::normal); + } +} + +OrganAudioSettingsDialog::~OrganAudioSettingsDialog() { + if (statThread_ != nullptr) { + statThread_->cancel(); + statThread_->stopThread(3000); + statThread_.reset(); + } +} + +void OrganAudioSettingsDialog::onScanProgress(double progress, int current, int total) { + scanProgress_ = progress; + scanStatusLabel_.setText( + juce::String::formatted("Calculating RAM footprint: %d%% (%d / %d files)...", + static_cast(progress * 100.0 + 0.5), current, total), + juce::dontSendNotification); +} + +void OrganAudioSettingsDialog::onScanCompleted(const OrganAudioStat& stat) { + audioStat_ = stat; + isScanning_ = false; + + scanProgressBar_.setVisible(false); + scanStatusLabel_.setVisible(false); + ramMeter_.setVisible(true); + ramDetailsLabel_.setVisible(true); + + rebuildDropdownItemTexts(); + + const juce::File sf = proc_.settingsFileFor(entry_.file); + if (!sf.existsAsFile()) { + const juce::int64 sysRamBytes = static_cast(juce::SystemStats::getMemorySizeInMegabytes()) * 1024 * 1024; + const juce::int64 osReservation = std::max(static_cast(4LL * 1024 * 1024 * 1024), + static_cast(sysRamBytes * 0.20)); + const juce::int64 availableForOrgan = std::max(0, sysRamBytes - osReservation); + + OrganAudioConfig q24; + q24.storage = SampleStorage::Int24; + q24.streamReleases = false; + q24.mono = false; + + OrganAudioConfig r16; + r16.storage = SampleStorage::Int16; + r16.streamReleases = true; + r16.mono = false; + + const juce::int64 ram24 = estimateRamFootprintBytes(audioStat_, q24); + const juce::int64 ram16 = estimateRamFootprintBytes(audioStat_, r16); + + if (ram24 > 0 && ram24 <= availableForOrgan) { + config_.storage = SampleStorage::Int24; + config_.streamReleases = false; + config_.mono = false; + } else if (ram16 > 0 && ram16 <= availableForOrgan) { + config_.storage = SampleStorage::Int16; + config_.streamReleases = true; + config_.mono = false; + } else if (ram16 > 0) { + config_.storage = SampleStorage::Int16; + config_.streamReleases = true; + config_.mono = true; + } + updateControlsFromConfig(); + } + + updateRamFootprintDisplay(); + repaint(); +} + +void OrganAudioSettingsDialog::rebuildDropdownItemTexts() { + const int currentProfileId = profileCombo_.getSelectedId(); + const int currentStorageId = storageCombo_.getSelectedId(); + + profileCombo_.clear(juce::dontSendNotification); + storageCombo_.clear(juce::dontSendNotification); + + OrganAudioConfig optBest; + optBest.storage = SampleStorage::Int24; + optBest.streamReleases = false; + optBest.mono = false; + + OrganAudioConfig optRec; + optRec.storage = SampleStorage::Int16; + optRec.streamReleases = true; + optRec.mono = false; + + OrganAudioConfig optSmall; + optSmall.storage = SampleStorage::Int16; + optSmall.streamReleases = true; + optSmall.mono = true; + + juce::String bestDesc = "Best quality - 24-bit, hold everything"; + juce::String recDesc = "Recommended - 16-bit, stream releases"; + juce::String smallDesc = "Smallest - 16-bit mono, stream releases"; + + if (audioStat_.hasStats) { + bestDesc += " (~" + formatByteSize(estimateRamFootprintBytes(audioStat_, optBest)) + " RAM)"; + recDesc += " (~" + formatByteSize(estimateRamFootprintBytes(audioStat_, optRec)) + " RAM)"; + smallDesc += " (~" + formatByteSize(estimateRamFootprintBytes(audioStat_, optSmall)) + " RAM)"; + } + + profileCombo_.addItem(bestDesc, 1); + profileCombo_.addItem(recDesc, 2); + profileCombo_.addItem(smallDesc, 3); + profileCombo_.addItem("Custom", 4); + + OrganAudioConfig s24 = config_; s24.storage = SampleStorage::Int24; + OrganAudioConfig s16 = config_; s16.storage = SampleStorage::Int16; + OrganAudioConfig s32 = config_; s32.storage = SampleStorage::Float32; + + juce::String s24Text = "24-bit - original sample format"; + juce::String s16Text = "16-bit - reduces memory by ~33%"; + juce::String s32Text = "32-bit float - higher memory usage"; + if (audioStat_.hasStats) { + s24Text += " (~" + formatByteSize(estimateRamFootprintBytes(audioStat_, s24)) + ")"; + s16Text += " (~" + formatByteSize(estimateRamFootprintBytes(audioStat_, s16)) + ")"; + s32Text += " (~" + formatByteSize(estimateRamFootprintBytes(audioStat_, s32)) + ")"; + } + + storageCombo_.addItem(s24Text, 1); + storageCombo_.addItem(s16Text, 2); + storageCombo_.addItem(s32Text, 3); + + if (currentProfileId > 0) profileCombo_.setSelectedId(currentProfileId, juce::dontSendNotification); + if (currentStorageId > 0) storageCombo_.setSelectedId(currentStorageId, juce::dontSendNotification); +} + +void OrganAudioSettingsDialog::syncProfile() { + const bool is24 = (config_.storage == SampleStorage::Int24); + const bool is16 = (config_.storage == SampleStorage::Int16); + const bool streaming = config_.streamReleases; + const bool mono = config_.mono; + const bool fullPreload = (config_.preloadHeadFrames == 0); + + if (is24 && !streaming && !mono && fullPreload) { + profileCombo_.setSelectedId(1, juce::dontSendNotification); + } else if (is16 && streaming && !mono && fullPreload) { + profileCombo_.setSelectedId(2, juce::dontSendNotification); + } else if (is16 && streaming && mono && fullPreload) { + profileCombo_.setSelectedId(3, juce::dontSendNotification); + } else { + profileCombo_.setSelectedId(4, juce::dontSendNotification); + } +} + +void OrganAudioSettingsDialog::updateRamFootprintDisplay() { + const juce::int64 sysRamBytes = static_cast(juce::SystemStats::getMemorySizeInMegabytes()) * 1024 * 1024; + const juce::int64 osReservation = std::max(static_cast(4LL * 1024 * 1024 * 1024), + static_cast(sysRamBytes * 0.20)); + const juce::int64 organBytes = estimateRamFootprintBytes(audioStat_, config_); + + ramMeter_.setValues(sysRamBytes, osReservation, organBytes); + + juce::String details; + if (organBytes > 0) { + const double organPct = (static_cast(organBytes) / static_cast(sysRamBytes)) * 100.0; + const double totalUsedPct = (static_cast(osReservation + organBytes) / static_cast(sysRamBytes)) * 100.0; + juce::String status = "Safe"; + if (totalUsedPct >= 88.0) status = "Danger: OS paging / out-of-memory risk"; + else if (totalUsedPct >= 75.0) status = "High memory load"; + else if (totalUsedPct >= 60.0) status = "Moderate"; + + details = "Organ RAM: ~" + formatByteSize(organBytes) + " (" + juce::String(organPct, 0) + "%), OS/System: ~" + + formatByteSize(osReservation) + " • Total: " + formatByteSize(sysRamBytes) + " • " + status; + } else { + details = "System RAM: " + formatByteSize(sysRamBytes) + " (OS buffer: " + formatByteSize(osReservation) + ")"; + } + ramDetailsLabel_.setText(details, juce::dontSendNotification); +} + +void OrganAudioSettingsDialog::updateControlsFromConfig() { + storageCombo_.setSelectedId(config_.storage == SampleStorage::Int16 ? 2 : (config_.storage == SampleStorage::Float32 ? 3 : 1), juce::dontSendNotification); + monoToggle_.setToggleState(config_.mono, juce::dontSendNotification); + rateCombo_.setSelectedId(config_.sampleRate == 48000.0 ? 2 : (config_.sampleRate == 44100.0 ? 3 : 1), juce::dontSendNotification); + streamToggle_.setToggleState(config_.streamReleases, juce::dontSendNotification); + preloadCombo_.setSelectedId(config_.preloadHeadFrames == 0 ? 1 : (config_.preloadHeadFrames >= 88200 ? 2 : (config_.preloadHeadFrames >= 44100 ? 3 : 4)), juce::dontSendNotification); + cacheCombo_.setSelectedId(config_.cacheMode == SampleLibrary::CacheMode::Off ? 3 : (config_.cacheMode == SampleLibrary::CacheMode::PerOrgan ? 2 : 1), juce::dontSendNotification); + + simpleWav_.setToggleState(config_.engineSwitch.simpleWavOnly, juce::dontSendNotification); + wind_.setToggleState(config_.engineSwitch.enableWindModel, juce::dontSendNotification); + tremulant_.setToggleState(config_.engineSwitch.enableTremulant, juce::dontSendNotification); + enclosure_.setToggleState(config_.engineSwitch.enableEnclosure, juce::dontSendNotification); + voicing_.setToggleState(config_.engineSwitch.enableVoicing, juce::dontSendNotification); + originalPitch_.setToggleState(config_.engineSwitch.playAtOriginalOrganPitch, juce::dontSendNotification); + + showOrganRoot(); + syncProfile(); +} + +void OrganAudioSettingsDialog::showOrganRoot() { + if (config_.organRootOverride.exists()) { + rootValue_.setText(config_.organRootOverride.getFullPathName(), juce::dontSendNotification); + } else { + rootValue_.setText("(Default: auto-detect from definition)", juce::dontSendNotification); + } +} + +void OrganAudioSettingsDialog::save() { + saveOrganAudioConfig(proc_, entry_.file, config_); + closeDialog(); +} + +void OrganAudioSettingsDialog::resetToDefaults() { + config_ = loadOrganAudioConfig(proc_, juce::File()); + updateControlsFromConfig(); + updateRamFootprintDisplay(); +} + +void OrganAudioSettingsDialog::closeDialog() { + if (auto* dw = findParentComponentOfClass()) + dw->exitModalState(0); +} + +void OrganAudioSettingsDialog::paint(juce::Graphics& g) { + g.fillAll(juce::Colour(0xff1b1e24)); + + g.setColour(juce::Colour(0xff272c36)); + g.fillRect(16, 360, getWidth() - 32, 1); + g.fillRect(16, 420, getWidth() - 32, 1); + g.fillRect(16, 574, getWidth() - 32, 1); +} + +void OrganAudioSettingsDialog::resized() { + const int pad = 16; + const int w = getWidth() - pad * 2; + int y = pad; + + titleLabel_.setBounds(pad, y, w, 22); + y += 24; + subtitleLabel_.setBounds(pad, y, w, 16); + y += 24; + + ramHeading_.setBounds(pad, y, w, 18); + y += 20; + ramMeter_.setBounds(pad, y, w, 20); + scanProgressBar_.setBounds(pad, y, w, 20); + y += 24; + ramDetailsLabel_.setBounds(pad, y, w, 16); + scanStatusLabel_.setBounds(pad, y, w, 16); + y += 26; + + profileLabel_.setBounds(pad, y, 120, 26); + profileCombo_.setBounds(pad + 124, y, w - 124, 26); + y += 34; + + const int halfW = (w - 10) / 2; + storageLabel_.setBounds(pad, y, 140, 24); + storageCombo_.setBounds(pad + 140, y, halfW - 140, 24); + + rateLabel_.setBounds(pad + halfW + 10, y, 90, 24); + rateCombo_.setBounds(pad + halfW + 100, y, halfW - 100, 24); + y += 30; + + monoToggle_.setBounds(pad, y, halfW, 24); + streamToggle_.setBounds(pad + halfW + 10, y, halfW, 24); + y += 30; + + preloadLabel_.setBounds(pad, y, 140, 24); + preloadCombo_.setBounds(pad + 140, y, halfW - 140, 24); + + cacheLabel_.setBounds(pad + halfW + 10, y, 90, 24); + cacheCombo_.setBounds(pad + halfW + 100, y, halfW - 100, 24); + y += 38; + + rootLabel_.setBounds(pad, y, w, 18); + y += 20; + rootValue_.setBounds(pad, y, w - 170, 24); + rootChooseBtn_.setBounds(pad + w - 165, y, 80, 24); + rootDefaultBtn_.setBounds(pad + w - 80, y, 80, 24); + y += 34; + + dspHeading_.setBounds(pad, y, w, 20); + y += 24; + + const int colW = halfW; + simpleWav_.setBounds(pad, y, colW, 22); + wind_.setBounds(pad + halfW + 10, y, colW, 22); + y += 24; + + tremulant_.setBounds(pad, y, colW, 22); + enclosure_.setBounds(pad + halfW + 10, y, colW, 22); + y += 24; + + voicing_.setBounds(pad, y, colW, 22); + originalPitch_.setBounds(pad + halfW + 10, y, colW, 22); + y += 30; + + const int bottomY = getHeight() - pad - 32; + saveBtn_.setBounds(pad, bottomY, 130, 32); + defaultsBtn_.setBounds(pad + 140, bottomY, 140, 32); + cancelBtn_.setBounds(getWidth() - pad - 80, bottomY, 80, 32); +} + +// ============================================================================== +// OverlayPanel implementation +// ============================================================================== +OrganDialog::OverlayPanel::OverlayPanel(double& progressRef) + : progressBar(progressRef) { + titleLabel.setText("Installing Organ Packages", juce::dontSendNotification); + titleLabel.setFont(juce::FontOptions(16.0f, juce::Font::bold)); + titleLabel.setColour(juce::Label::textColourId, juce::Colour(0xffe8edf5)); + titleLabel.setJustificationType(juce::Justification::centred); + addAndMakeVisible(titleLabel); + + archiveLabel.setFont(juce::FontOptions(13.0f, juce::Font::bold)); + archiveLabel.setColour(juce::Label::textColourId, juce::Colour(0xff68b2ff)); + archiveLabel.setJustificationType(juce::Justification::centred); + addAndMakeVisible(archiveLabel); + + etaLabel.setFont(juce::FontOptions(12.0f)); + etaLabel.setColour(juce::Label::textColourId, juce::Colour(0xffa0abbd)); + etaLabel.setJustificationType(juce::Justification::centred); + addAndMakeVisible(etaLabel); + + progressBar.setColour(juce::ProgressBar::foregroundColourId, juce::Colour(0xff4a90e2)); + progressBar.setColour(juce::ProgressBar::backgroundColourId, juce::Colour(0xff121418)); + addAndMakeVisible(progressBar); + + fileLabel.setFont(juce::FontOptions(11.0f)); + fileLabel.setColour(juce::Label::textColourId, juce::Colour(0xff8a95a5)); + fileLabel.setJustificationType(juce::Justification::centred); + addAndMakeVisible(fileLabel); + + cancelBtn.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff3b2424)); + cancelBtn.setColour(juce::TextButton::textColourOffId, juce::Colour(0xffe58787)); + cancelBtn.onClick = [this] { + cancelBtn.setEnabled(false); + cancelBtn.setButtonText("Cancelling..."); + fileLabel.setText("Cancelling extraction...", juce::dontSendNotification); + if (onCancel) onCancel(); + }; + addAndMakeVisible(cancelBtn); +} + +void OrganDialog::OverlayPanel::paint(juce::Graphics& g) { + g.fillAll(juce::Colour(0xd00e1014)); + + auto card = getLocalBounds().withSizeKeepingCentre( + std::min(520, getWidth() - 32), std::min(250, getHeight() - 32)); + + g.setColour(juce::Colour(0xff1c2027)); + g.fillRoundedRectangle(card.toFloat(), 8.0f); + + g.setColour(juce::Colour(0xff2f3a4d)); + g.drawRoundedRectangle(card.toFloat(), 8.0f, 1.0f); +} + +void OrganDialog::OverlayPanel::resized() { + auto card = getLocalBounds().withSizeKeepingCentre( + std::min(520, getWidth() - 32), std::min(250, getHeight() - 32)); + + int y = card.getY() + 18; + const int w = card.getWidth() - 32; + const int x = card.getX() + 16; + + titleLabel.setBounds(x, y, w, 24); + y += 28; + archiveLabel.setBounds(x, y, w, 20); + y += 22; + etaLabel.setBounds(x, y, w, 18); + y += 24; + progressBar.setBounds(x, y, w, 22); + y += 26; + fileLabel.setBounds(x, y, w, 18); + y += 26; + cancelBtn.setBounds(card.getCentreX() - 50, y, 100, 28); +} + +// ============================================================================== +// InstallThread implementation +// ============================================================================== +class OrganDialog::InstallThread : public juce::Thread { +public: + InstallThread(OrganDialog& owner, const juce::File& unrar, + const juce::Array& archives, + const juce::File& destDir) + : juce::Thread("OrganPackageInstaller"), + owner_(&owner), + unrar_(unrar), + archives_(archives), + destDir_(destDir) {} + + ~InstallThread() override { + cancel(); + stopThread(3000); + } + + void cancel() { + signalThreadShouldExit(); + juce::ScopedLock sl(lock_); + if (process_.isRunning()) { + process_.kill(); + } + } + + void run() override { + destDir_.createDirectory(); + const int total = archives_.size(); + int succeeded = 0; + juce::String lastError; + + juce::Array archSizes; + juce::int64 totalBytes = 0; + for (const auto& a : archives_) { + const juce::int64 sz = a.getSize(); + archSizes.add(sz); + totalBytes += sz; + } + juce::int64 completedBytes = 0; + + for (int i = 0; i < total; ++i) { + if (threadShouldExit()) break; + + const auto& arch = archives_[i]; + const juce::String archName = arch.getFileName(); + const juce::int64 thisSize = archSizes[i]; + + juce::StringArray args; + args.add(unrar_.getFullPathName()); + args.add("x"); + args.add("-o+"); + args.add("-inul"); + args.add(arch.getFullPathName()); + args.add(destDir_.getFullPathName() + juce::File::getSeparatorString()); + + juce::String commandLine; + for (const auto& a : args) { + if (a.containsChar(' ')) { + commandLine += "\"" + a + "\" "; + } else { + commandLine += a + " "; + } + } + + { + juce::ScopedLock sl(lock_); + if (threadShouldExit()) break; + if (!process_.start(commandLine.trim(), juce::ChildProcess::wantStdErr | juce::ChildProcess::wantStdOut)) { + lastError = "Failed to launch unrar for " + archName; + continue; + } + } + + juce::String currentFile; + double subProgress = 0.0; + char buffer[512]; + + while (process_.isRunning() && !threadShouldExit()) { + const int bytesRead = process_.readProcessOutput(buffer, sizeof(buffer) - 1); + if (bytesRead > 0) { + buffer[bytesRead] = '\0'; + juce::String outputChunk(buffer); + for (int cIdx = 0; cIdx < outputChunk.length(); ++cIdx) { + if (outputChunk[cIdx] == '%' && cIdx >= 2) { + int startNum = cIdx - 1; + while (startNum >= 0 && juce::CharacterFunctions::isDigit(outputChunk[startNum])) { + startNum--; + } + startNum++; + int pct = outputChunk.substring(startNum, cIdx).getIntValue(); + if (pct >= 0 && pct <= 100) { + subProgress = pct / 100.0; + } + } + } + + int extrIdx = outputChunk.lastIndexOf("Extracting "); + if (extrIdx >= 0) { + juce::String tail = outputChunk.substring(extrIdx + 11).trim(); + int endLine = tail.indexOfAnyOf("\r\n"); + if (endLine > 0) tail = tail.substring(0, endLine).trim(); + if (tail.isNotEmpty()) currentFile = tail; + } + } + + double overallProgress = 0.0; + if (totalBytes > 0) { + overallProgress = (static_cast(completedBytes) + static_cast(thisSize) * subProgress) / + static_cast(totalBytes); + } else { + overallProgress = (static_cast(i) + subProgress) / static_cast(total); + } + overallProgress = juce::jlimit(0.0, 0.999, overallProgress); + + juce::MessageManager::callAsync([owner = owner_, i, total, archName, overallProgress, subProgress, currentFile] { + if (owner != nullptr) { + owner->updateInstallProgress(i + 1, total, archName, overallProgress, subProgress, currentFile); + } + }); + + juce::Thread::sleep(40); + } + + const int exitCode = process_.getExitCode(); + if (threadShouldExit()) { + break; + } + + if (exitCode == 0) { + succeeded++; + } else { + lastError = "unrar returned exit code " + juce::String(exitCode) + " on " + archName; + } + + completedBytes += thisSize; + } + + const bool aborted = threadShouldExit(); + juce::MessageManager::callAsync([owner = owner_, succeeded, total, aborted, lastError] { + if (owner != nullptr) { + owner->installFinished(succeeded, total, aborted, lastError); + } + }); + + if (!aborted && succeeded > 0) { + triggerDirectoryAudioStatPrecomputation(destDir_); + } + } + +private: + juce::Component::SafePointer owner_; + const juce::File unrar_; + const juce::Array archives_; + const juce::File destDir_; + juce::CriticalSection lock_; + juce::ChildProcess process_; +}; + +// ============================================================================== +// OrganDialog implementation +// ============================================================================== +OrganDialog::OrganDialog(MasterpieceEditor& editor, MasterpieceProcessor& proc) + : editor_(editor), proc_(proc) { + titleLabel_.setText("Organs", juce::dontSendNotification); + titleLabel_.setFont(juce::FontOptions(20.0f, juce::Font::bold)); + titleLabel_.setColour(juce::Label::textColourId, juce::Colour(0xffe8edf5)); + addAndMakeVisible(titleLabel_); + + subtitleLabel_.setText( + "Select an installed organ, configure audio, inspect package dependencies, or unpack Hauptwerk RAR packages.", + juce::dontSendNotification); + subtitleLabel_.setFont(juce::FontOptions(12.0f)); + subtitleLabel_.setColour(juce::Label::textColourId, juce::Colour(0xff8a95a5)); + addAndMakeVisible(subtitleLabel_); + + filterBox_.setTextToShowWhenEmpty("Filter organs...", juce::Colour(0xff6a7585)); + filterBox_.setColour(juce::TextEditor::backgroundColourId, juce::Colour(0xff121418)); + filterBox_.setColour(juce::TextEditor::textColourId, juce::Colour(0xffe8edf5)); + filterBox_.setColour(juce::TextEditor::outlineColourId, juce::Colour(0xff2f3a4d)); + filterBox_.setColour(juce::TextEditor::focusedOutlineColourId, juce::Colour(0xff4a90e2)); + filterBox_.onTextChange = [this] { updateFilter(); }; + addAndMakeVisible(filterBox_); + + listBox_.setModel(this); + listBox_.setRowHeight(48); + listBox_.setColour(juce::ListBox::backgroundColourId, juce::Colour(0xff121418)); + listBox_.setColour(juce::ListBox::outlineColourId, juce::Colour(0xff232833)); + addAndMakeVisible(listBox_); + + // Row 1 buttons: Selected organ actions + loadBtn_.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff2d4a6e)); + loadBtn_.setColour(juce::TextButton::textColourOffId, juce::Colour(0xffe8edf5)); + loadBtn_.onClick = [this] { loadSelected(); }; + addAndMakeVisible(loadBtn_); + + adjustAudioBtn_.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff2a3442)); + adjustAudioBtn_.setColour(juce::TextButton::textColourOffId, juce::Colour(0xffc2d4ea)); + adjustAudioBtn_.onClick = [this] { adjustAudioSettings(); }; + addAndMakeVisible(adjustAudioBtn_); + + detailsBtn_.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff232833)); + detailsBtn_.setColour(juce::TextButton::textColourOffId, juce::Colour(0xffc2c8d2)); + detailsBtn_.onClick = [this] { showDetails(); }; + addAndMakeVisible(detailsBtn_); + + removeBtn_.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff3b2424)); + removeBtn_.setColour(juce::TextButton::textColourOffId, juce::Colour(0xfff09898)); + removeBtn_.onClick = [this] { removeSelected(); }; + addAndMakeVisible(removeBtn_); + + // Row 2 buttons: General actions + openOdfBtn_.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff232833)); + openOdfBtn_.setColour(juce::TextButton::textColourOffId, juce::Colour(0xffc2c8d2)); + openOdfBtn_.onClick = [this] { openOdf(); }; + addAndMakeVisible(openOdfBtn_); + + installBtn_.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff243b30)); + installBtn_.setColour(juce::TextButton::textColourOffId, juce::Colour(0xff98e2b0)); + installBtn_.onClick = [this] { installPackages(); }; + addAndMakeVisible(installBtn_); + + loadBtn_.setEnabled(false); + adjustAudioBtn_.setEnabled(false); + detailsBtn_.setEnabled(false); + removeBtn_.setEnabled(false); + + installPanel_.onCancel = [this] { cancelInstallation(); }; + addChildComponent(installPanel_); + + refreshList(); +} + +OrganDialog::~OrganDialog() { + stopTimer(); + if (installThread_ != nullptr) { + installThread_->cancel(); + installThread_->stopThread(3000); + installThread_.reset(); + } +} + +void OrganDialog::show(MasterpieceEditor& editor, MasterpieceProcessor& proc) { + auto content = std::make_unique(editor, proc); + content->setSize(760, 560); + juce::DialogWindow::LaunchOptions o; + o.content.setOwned(content.release()); + o.dialogTitle = "Organs"; + o.dialogBackgroundColour = juce::Colour(0xff1b1e24); + o.escapeKeyTriggersCloseButton = true; + o.useNativeTitleBar = true; + o.resizable = true; + o.launchAsync(); +} + +void OrganDialog::closeDialog() { + if (installThread_ != nullptr) { + installThread_->cancel(); + installThread_->stopThread(3000); + installThread_.reset(); + } + if (auto* dw = findParentComponentOfClass()) { + dw->exitModalState(0); + } +} + +void OrganDialog::refreshList() { + allOrgans_ = discoverOrgans(proc_); + updateFilter(); +} + +void OrganDialog::updateFilter() { + const auto filter = filterBox_.getText().trim(); + filteredOrgans_.clear(); + for (const auto& entry : allOrgans_) { + if (filter.isEmpty() || + entry.name.containsIgnoreCase(filter) || + entry.file.getFileName().containsIgnoreCase(filter)) { + filteredOrgans_.push_back(entry); + } + } + listBox_.updateContent(); + + for (int i = 0; i < static_cast(filteredOrgans_.size()); ++i) { + if (filteredOrgans_[i].isCurrent) { + listBox_.selectRow(i); + break; + } + } + selectedRowsChanged(listBox_.getSelectedRow()); +} + +void OrganDialog::resized() { + const int pad = 16; + const int w = getWidth() - pad * 2; + + titleLabel_.setBounds(pad, 14, w, 26); + subtitleLabel_.setBounds(pad, 40, w, 18); + filterBox_.setBounds(pad, 64, w, 28); + + const int bottomH = 32; + const int gap = 8; + const int row2Y = getHeight() - pad - bottomH; + const int row1Y = row2Y - gap - bottomH; + + const int listY = 100; + listBox_.setBounds(pad, listY, w, row1Y - gap - listY); + + loadBtn_.setBounds(pad, row1Y, 80, bottomH); + adjustAudioBtn_.setBounds(pad + 88, row1Y, 175, bottomH); + detailsBtn_.setBounds(pad + 88 + 175 + gap, row1Y, 90, bottomH); + removeBtn_.setBounds(pad + 88 + 175 + gap + 90 + gap, row1Y, 90, bottomH); + + openOdfBtn_.setBounds(pad, row2Y, 110, bottomH); + installBtn_.setBounds(pad + 118, row2Y, 195, bottomH); + + installPanel_.setBounds(getLocalBounds()); +} + +void OrganDialog::paint(juce::Graphics& g) { + g.fillAll(juce::Colour(0xff1b1e24)); +} + +int OrganDialog::getNumRows() { + return static_cast(filteredOrgans_.size()); +} + +void OrganDialog::paintListBoxItem(int rowNumber, juce::Graphics& g, int width, int height, + bool rowIsSelected) { + if (rowNumber < 0 || rowNumber >= static_cast(filteredOrgans_.size())) + return; + + const auto& entry = filteredOrgans_[rowNumber]; + + if (rowIsSelected) { + g.setColour(juce::Colour(0xff23354d)); + g.fillRect(0, 0, width, height); + } else if (rowNumber % 2 == 1) { + g.setColour(juce::Colour(0xff16181f)); + g.fillRect(0, 0, width, height); + } + + if (entry.isCurrent) { + g.setColour(juce::Colour(0xff4a90e2)); + g.fillRect(0, 0, 4, height); + } + + const int leftMargin = 16; + const int rightMargin = 120; + const int contentWidth = width - leftMargin - rightMargin; + + g.setFont(juce::FontOptions(14.0f, entry.isCurrent ? juce::Font::bold : juce::Font::plain)); + g.setColour(entry.exists ? (entry.isCurrent ? juce::Colour(0xff68b2ff) : juce::Colour(0xffe8edf5)) + : juce::Colour(0xff6e7888)); + g.drawText(entry.name, leftMargin, 4, contentWidth, 20, juce::Justification::left, true); + + g.setFont(juce::FontOptions(11.0f)); + g.setColour(juce::Colour(0xff758092)); + juce::String pathDisplay = entry.file.getFullPathName(); + const auto dataDir = MasterpieceProcessor::dataDirectory(); + if (entry.file.isAChildOf(dataDir)) { + pathDisplay = "OrganDefinitions/" + entry.file.getRelativePathFrom(dataDir.getChildFile("OrganDefinitions")); + } + + juce::String sub = pathDisplay; + if (entry.diskSpaceBytes > 0) { + sub += " \u2022 " + formatByteSize(entry.diskSpaceBytes); + } + if (!entry.packages.empty()) { + int inst = 0; + for (const auto& p : entry.packages) { + if (p.isInstalled) inst++; + } + if (inst < static_cast(entry.packages.size())) { + sub += " \u2022 " + juce::String(inst) + "/" + juce::String(entry.packages.size()) + " pkgs"; + } + } + g.drawText(sub, leftMargin, 26, contentWidth, 16, juce::Justification::left, true); + + auto badgeRect = juce::Rectangle(width - 110, (height - 20) / 2, 96, 20); + if (entry.isCurrent) { + g.setColour(juce::Colour(0xff1b3d2b)); + g.fillRoundedRectangle(badgeRect.toFloat(), 4.0f); + g.setColour(juce::Colour(0xff68d391)); + g.setFont(juce::FontOptions(11.0f, juce::Font::bold)); + g.drawText("Loaded", badgeRect, juce::Justification::centred, false); + } else if (!entry.exists) { + g.setColour(juce::Colour(0xff3d1f1f)); + g.fillRoundedRectangle(badgeRect.toFloat(), 4.0f); + g.setColour(juce::Colour(0xffe28080)); + g.setFont(juce::FontOptions(11.0f)); + g.drawText("Missing", badgeRect, juce::Justification::centred, false); + } else { + bool missingAnyPackage = false; + for (const auto& p : entry.packages) { + if (!p.isInstalled) { + missingAnyPackage = true; + break; + } + } + + if (missingAnyPackage) { + g.setColour(juce::Colour(0xff382e1d)); + g.fillRoundedRectangle(badgeRect.toFloat(), 4.0f); + g.setColour(juce::Colour(0xffe2be80)); + g.setFont(juce::FontOptions(11.0f)); + g.drawText("Missing Pkg", badgeRect, juce::Justification::centred, false); + } else if (entry.isInstalled) { + g.setColour(juce::Colour(0xff222a36)); + g.fillRoundedRectangle(badgeRect.toFloat(), 4.0f); + g.setColour(juce::Colour(0xff98a9c2)); + g.setFont(juce::FontOptions(11.0f)); + g.drawText("Installed", badgeRect, juce::Justification::centred, false); + } + } + + g.setColour(juce::Colour(0xff20242c)); + g.fillRect(0, height - 1, width, 1); +} + +void OrganDialog::listBoxItemDoubleClicked(int row, const juce::MouseEvent&) { + if (row >= 0 && row < static_cast(filteredOrgans_.size())) { + const auto& entry = filteredOrgans_[row]; + if (entry.exists) { + loadSelected(); + } + } +} + +void OrganDialog::selectedRowsChanged(int lastRowSelected) { + const bool valid = (lastRowSelected >= 0 && + lastRowSelected < static_cast(filteredOrgans_.size())); + const bool exists = valid && filteredOrgans_[lastRowSelected].exists; + loadBtn_.setEnabled(exists); + adjustAudioBtn_.setEnabled(valid); + detailsBtn_.setEnabled(valid); + removeBtn_.setEnabled(valid); +} + +void OrganDialog::deleteKeyPressed(int lastRowSelected) { + juce::ignoreUnused(lastRowSelected); + removeSelected(); +} + +void OrganDialog::returnKeyPressed(int lastRowSelected) { + if (lastRowSelected >= 0 && lastRowSelected < static_cast(filteredOrgans_.size())) { + loadSelected(); + } +} + +void OrganDialog::loadSelected() { + const int row = listBox_.getSelectedRow(); + if (row < 0 || row >= static_cast(filteredOrgans_.size())) return; + const auto& entry = filteredOrgans_[row]; + if (!entry.exists) return; + + proc_.addRecentOrgan(entry.file); + editor_.loadOrgan(entry.file); + closeDialog(); +} + +void OrganDialog::adjustAudioSettings() { + const int row = listBox_.getSelectedRow(); + if (row < 0 || row >= static_cast(filteredOrgans_.size())) return; + const auto entry = filteredOrgans_[row]; + + auto panel = std::make_unique(entry, proc_); + juce::DialogWindow::LaunchOptions opts; + opts.content.setOwned(panel.release()); + opts.dialogTitle = "Audio Settings - " + entry.name; + opts.dialogBackgroundColour = juce::Colour(0xff1b1e24); + opts.escapeKeyTriggersCloseButton = true; + opts.useNativeTitleBar = true; + opts.resizable = true; + opts.launchAsync(); +} + +void OrganDialog::showDetails() { + const int row = listBox_.getSelectedRow(); + if (row < 0 || row >= static_cast(filteredOrgans_.size())) return; + const auto entry = filteredOrgans_[row]; + + auto panel = std::make_unique(entry, proc_, [this] { + adjustAudioSettings(); + }); + juce::DialogWindow::LaunchOptions opts; + opts.content.setOwned(panel.release()); + opts.dialogTitle = "Organ Details - " + entry.name; + opts.dialogBackgroundColour = juce::Colour(0xff1b1e24); + opts.escapeKeyTriggersCloseButton = true; + opts.useNativeTitleBar = true; + opts.resizable = true; + opts.launchAsync(); +} + +void OrganDialog::removeSelected() { + const int row = listBox_.getSelectedRow(); + if (row < 0 || row >= static_cast(filteredOrgans_.size())) return; + const auto entry = filteredOrgans_[row]; + + juce::StringArray options; + options.add("Remove from list (keep files on disk)"); + options.add("Delete organ and files permanently"); + options.add("Cancel"); + + juce::String extraMsg; + if (entry.diskSpaceBytes > 0) { + extraMsg = "\nTotal disk space used: " + formatByteSize(entry.diskSpaceBytes); + } + + auto* aw = new juce::AlertWindow( + "Remove Organ - " + entry.name, + "What would you like to do with \"" + entry.name + "\"?" + extraMsg, + juce::AlertWindow::QuestionIcon); + aw->addButton("Remove from List", 1); + aw->addButton("Delete from Disk", 2); + aw->addButton("Cancel", 0); + aw->enterModalState(true, juce::ModalCallbackFunction::create([this, entry](int result) { + if (result == 1) { + proc_.hideOrgan(entry.file); + proc_.removeRecentOrgan(entry.file); + refreshList(); + } else if (result == 2) { + juce::AlertWindow::showOkCancelBox( + juce::AlertWindow::WarningIcon, "Confirm Permanent Deletion", + "Are you sure you want to permanently delete \"" + entry.name + "\"?\n\n" + "This will delete the organ definition and its associated installation package files from disk.\n" + "This action cannot be undone.", + "Delete Permanently", "Cancel", nullptr, + juce::ModalCallbackFunction::create([this, entry](int confirmResult) { + if (confirmResult == 1) { + performDeleteOrgan(entry); + } + })); + } + }), true); +} + +void OrganDialog::performDeleteOrgan(const OrganEntry& entry) { + juce::int64 freedBytes = 0; + + if (entry.file.existsAsFile()) { + freedBytes += entry.file.getSize(); + entry.file.deleteFile(); + } + + const auto dataDir = MasterpieceProcessor::dataDirectory(); + for (const auto& pkg : entry.packages) { + if (pkg.isInstalled && pkg.directory.isDirectory()) { + if (pkg.directory.isAChildOf(dataDir)) { + freedBytes += computeDirectorySize(pkg.directory); + pkg.directory.deleteRecursively(); + } + } + } + + proc_.hideOrgan(entry.file); + proc_.removeRecentOrgan(entry.file); + refreshList(); + + juce::String msg = "\"" + entry.name + "\" has been deleted from disk"; + if (freedBytes > 0) { + msg += ".\nFreed " + formatByteSize(freedBytes) + " of disk space."; + } + juce::AlertWindow::showMessageBoxAsync(juce::AlertWindow::InfoIcon, "Organ Deleted", msg); +} + +void OrganDialog::openOdf() { + chooser_ = std::make_unique( + "Choose an organ definition file", juce::File(), + "*.Organ_Hauptwerk_xml;*.CustomOrgan_Hauptwerk_xml;*.organ_hauptwerk_xml;*.customorgan_hauptwerk_xml"); + chooser_->launchAsync( + juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles, + [this](const juce::FileChooser& fc) { + const auto file = fc.getResult(); + if (file.existsAsFile()) { + proc_.unhideOrgan(file); + proc_.addRecentOrgan(file); + editor_.loadOrgan(file); + closeDialog(); + } + }); +} + +void OrganDialog::installPackages() { + const auto unrar = findUnrarBinary(); + if (!unrar.existsAsFile()) { + juce::AlertWindow::showMessageBoxAsync( + juce::AlertWindow::WarningIcon, "unrar not found", + "The unrar binary could not be found.\n\nPlease download the nonfree unrar binary and place it into Masterpiece's data folder:\n" + + MasterpieceProcessor::dataDirectory().getFullPathName() + + "\nor install it onto your system via Homebrew ('brew install unrar') or package manager."); + return; + } + + chooser_ = std::make_unique( + "Select Hauptwerk Organ RAR Archive(s) to Install", juce::File(), + "*.rar;*.part1.rar;*.r00;*.CompPkg_Hauptwerk_rar;*.RAR;*.PART1.RAR"); + chooser_->launchAsync( + juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles | + juce::FileBrowserComponent::canSelectMultipleItems, + [this](const juce::FileChooser& fc) { + const auto results = fc.getResults(); + if (results.isEmpty()) return; + + const auto filtered = filterArchivesForExtraction(results); + if (filtered.isEmpty()) { + juce::AlertWindow::showMessageBoxAsync( + juce::AlertWindow::InfoIcon, "Installation", + "No valid RAR archive volumes found in selection."); + return; + } + + startExtraction(filtered); + }); +} + +void OrganDialog::startExtraction(const juce::Array& archives) { + const auto unrar = findUnrarBinary(); + const auto destDir = MasterpieceProcessor::dataDirectory(); + + installProgress_ = 0.0; + installStartTimeMs_ = juce::Time::getMillisecondCounterHiRes(); + installEtaSeconds_ = -1.0; + installPanel_.titleLabel.setText("Installing " + juce::String(archives.size()) + " Organ Package(s)", juce::dontSendNotification); + installPanel_.archiveLabel.setText("Preparing...", juce::dontSendNotification); + installPanel_.etaLabel.setText("Calculating time remaining...", juce::dontSendNotification); + installPanel_.fileLabel.setText("", juce::dontSendNotification); + installPanel_.cancelBtn.setEnabled(true); + installPanel_.cancelBtn.setButtonText("Cancel"); + installPanel_.setVisible(true); + + listBox_.setEnabled(false); + loadBtn_.setEnabled(false); + adjustAudioBtn_.setEnabled(false); + detailsBtn_.setEnabled(false); + removeBtn_.setEnabled(false); + openOdfBtn_.setEnabled(false); + installBtn_.setEnabled(false); + + installThread_ = std::make_unique(*this, unrar, archives, destDir); + installThread_->startThread(); + startTimer(200); +} + +void OrganDialog::cancelInstallation() { + if (installThread_ != nullptr) { + installThread_->cancel(); + } +} + +void OrganDialog::timerCallback() { + if (installPanel_.isVisible()) { + installPanel_.repaint(); + } +} + +void OrganDialog::updateInstallProgress(int currentArchIdx, int totalArchs, const juce::String& archName, + double overallProgress, double subProgress, const juce::String& currentFile) { + juce::ignoreUnused(subProgress); + installProgress_ = overallProgress; + installPanel_.archiveLabel.setText( + juce::String::formatted("Archive %d of %d: %s", currentArchIdx, totalArchs, archName.toRawUTF8()), + juce::dontSendNotification); + + if (currentFile.isNotEmpty()) { + installPanel_.fileLabel.setText(currentFile, juce::dontSendNotification); + } + + const double nowMs = juce::Time::getMillisecondCounterHiRes(); + const double elapsedSecs = std::max(0.1, (nowMs - installStartTimeMs_) / 1000.0); + + if (overallProgress > 0.02 && elapsedSecs >= 1.0) { + const double rawEta = (elapsedSecs / overallProgress) * (1.0 - overallProgress); + if (installEtaSeconds_ < 0.0) { + installEtaSeconds_ = rawEta; + } else { + installEtaSeconds_ = 0.85 * installEtaSeconds_ + 0.15 * rawEta; + } + installPanel_.etaLabel.setText(humaniseEta(installEtaSeconds_) + " remaining", juce::dontSendNotification); + } else { + installPanel_.etaLabel.setText("Calculating time remaining...", juce::dontSendNotification); + } +} + +void OrganDialog::installFinished(int succeeded, int total, bool aborted, const juce::String& error) { + stopTimer(); + installPanel_.setVisible(false); + listBox_.setEnabled(true); + openOdfBtn_.setEnabled(true); + installBtn_.setEnabled(true); + + if (installThread_ != nullptr) { + installThread_->stopThread(1000); + installThread_.reset(); + } + + refreshList(); + + if (aborted) { + juce::AlertWindow::showMessageBoxAsync( + juce::AlertWindow::InfoIcon, "Installation Cancelled", + "Package extraction was cancelled by user.\n" + juce::String(succeeded) + " of " + juce::String(total) + " package(s) extracted."); + } else if (succeeded == total) { + juce::AlertWindow::showMessageBoxAsync( + juce::AlertWindow::InfoIcon, "Installation Complete", + "Successfully extracted and installed " + juce::String(succeeded) + " package(s) into Masterpiece."); + } else { + juce::AlertWindow::showMessageBoxAsync( + juce::AlertWindow::WarningIcon, "Installation Finished With Errors", + juce::String(succeeded) + " of " + juce::String(total) + " package(s) extracted successfully.\n\n" + + (error.isNotEmpty() ? error : "Some packages failed to extract.")); + } +} + +} // namespace mp::ui diff --git a/src/mp_ui/OrganDialog.h b/src/mp_ui/OrganDialog.h new file mode 100644 index 0000000..825eb37 --- /dev/null +++ b/src/mp_ui/OrganDialog.h @@ -0,0 +1,319 @@ +#pragma once + +#include +#include +#include +#include "mp_audio/SampleLibrary.h" +#include +#include +#include +#include + +namespace mp { +class MasterpieceProcessor; +} + +namespace mp::ui { + +class MasterpieceEditor; + +struct OrganPackageInfo { + uint32_t packageId = 0; + juce::String name; + juce::String supplierName; + juce::File directory; + bool isInstalled = false; + juce::int64 diskSizeBytes = 0; +}; + +struct OrganEntry { + juce::File file; + juce::String name; + juce::String uniqueOrganId; + juce::File organRootDir; + bool isCurrent = false; + bool exists = false; + bool isInstalled = false; + juce::int64 odfSizeBytes = 0; + juce::int64 diskSpaceBytes = 0; + std::vector packages; +}; + +struct OrganAudioConfig { + SampleStorage storage = SampleStorage::Int24; + bool mono = false; + double sampleRate = 0.0; + SampleLibrary::CacheMode cacheMode = SampleLibrary::CacheMode::Single; + bool streamReleases = false; + juce::int64 streamHeadFrames = 44100; + juce::int64 preloadHeadFrames = 0; + juce::File organRootOverride; + EngineSwitch engineSwitch; +}; + +struct OrganAudioStat { + juce::int64 totalAudioFrames = 0; + juce::int64 attackFrames = 0; + juce::int64 releaseFrames = 0; + juce::int64 attackLoopFrames = 0; + int attackCount = 0; + int releaseCount = 0; + juce::int64 rawPcmBytes = 0; + bool hasStats = false; +}; + +// Utilities +juce::File findUnrarBinary(); +juce::Array filterArchivesForExtraction(const juce::Array& files); +juce::String readOrganNameFromOdf(const juce::File& file); +std::vector discoverOrgans(const MasterpieceProcessor& proc); +OrganEntry getOrganDetails(const juce::File& odfFile, const MasterpieceProcessor& proc); +juce::String formatByteSize(juce::int64 bytes); +juce::int64 computeDirectorySize(const juce::File& dir); +juce::File getOrganAudioStatCacheFile(const OrganEntry& entry); +bool hasCachedOrganAudioStat(const OrganEntry& entry); +OrganAudioStat computeOrganAudioStat( + const OrganEntry& entry, + const MasterpieceProcessor& proc, + std::function progressCallback = nullptr, + std::atomic* cancelFlag = nullptr); +juce::int64 estimateRamFootprintBytes(const OrganAudioStat& stat, const OrganAudioConfig& cfg); +OrganAudioConfig loadOrganAudioConfig(const MasterpieceProcessor& proc, const juce::File& odf); +bool saveOrganAudioConfig(MasterpieceProcessor& proc, const juce::File& odf, const OrganAudioConfig& cfg); +void triggerBackgroundAudioStatPrecomputation(const juce::File& odfFile); +void triggerDirectoryAudioStatPrecomputation(const juce::File& dir); +juce::String humaniseEta(double secs); + +class OrganDetailsDialog : public juce::Component, + public juce::ListBoxModel { +public: + OrganDetailsDialog(const OrganEntry& entry, MasterpieceProcessor& proc, + std::function onOpenAudioSettings); + ~OrganDetailsDialog() override = default; + + void resized() override; + void paint(juce::Graphics& g) override; + + int getNumRows() override; + void paintListBoxItem(int rowNumber, juce::Graphics& g, int width, int height, + bool rowIsSelected) override; + +private: + OrganEntry entry_; + MasterpieceProcessor& proc_; + std::function onOpenAudioSettings_; + + juce::Label titleLabel_; + juce::Label pathLabel_; + juce::Label rootLabel_; + juce::Label sizeLabel_; + juce::Label settingsLabel_; + juce::Label packagesHeaderLabel_; + + juce::ListBox packageList_; + + juce::TextButton adjustAudioBtn_{"Adjust Audio Settings..."}; + juce::TextButton revealBtn_{"Reveal in Finder"}; + juce::TextButton closeBtn_{"Close"}; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(OrganDetailsDialog) +}; + +// Visual graph showing system RAM distribution and organ footprint +class RamGraphMeterComponent : public juce::Component { +public: + RamGraphMeterComponent(); + ~RamGraphMeterComponent() override = default; + + void setValues(juce::int64 totalSystemRamBytes, + juce::int64 osUsedRamBytes, + juce::int64 organFootprintBytes); + + void paint(juce::Graphics& g) override; + +private: + juce::int64 totalSystemRam_ = 0; + juce::int64 osUsedRam_ = 0; + juce::int64 organFootprint_ = 0; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(RamGraphMeterComponent) +}; + +class OrganAudioSettingsDialog : public juce::Component { +public: + OrganAudioSettingsDialog(const OrganEntry& entry, MasterpieceProcessor& proc); + ~OrganAudioSettingsDialog() override; + + void resized() override; + void paint(juce::Graphics& g) override; + +private: + class StatScanThread; + std::unique_ptr statThread_; + + void onScanProgress(double progress, int current, int total); + void onScanCompleted(const OrganAudioStat& stat); + + void syncProfile(); + void updateControlsFromConfig(); + void updateRamFootprintDisplay(); + void rebuildDropdownItemTexts(); + void showOrganRoot(); + void save(); + void resetToDefaults(); + void closeDialog(); + + OrganEntry entry_; + MasterpieceProcessor& proc_; + OrganAudioConfig config_; + OrganAudioStat audioStat_; + + bool isScanning_ = false; + double scanProgress_ = 0.0; + juce::ProgressBar scanProgressBar_{scanProgress_}; + juce::Label scanStatusLabel_; + + juce::Label titleLabel_; + juce::Label subtitleLabel_; + + // RAM Usage Graph & breakdown + juce::Label ramHeading_; + RamGraphMeterComponent ramMeter_; + juce::Label ramDetailsLabel_; + + // Memory profile + juce::Label profileLabel_; + juce::ComboBox profileCombo_; + + // Resident sample format + juce::Label storageLabel_; + juce::ComboBox storageCombo_; + + // Channels + juce::ToggleButton monoToggle_{"Load in mono (halves RAM, sums stereo to mono)"}; + + // Sample rate + juce::Label rateLabel_; + juce::ComboBox rateCombo_; + + // Streaming & preload + juce::ToggleButton streamToggle_{"Stream release tails from disk"}; + juce::Label preloadLabel_; + juce::ComboBox preloadCombo_; + + // Cache + juce::Label cacheLabel_; + juce::ComboBox cacheCombo_; + + // Organ root override + juce::Label rootLabel_; + juce::Label rootValue_; + juce::TextButton rootChooseBtn_{"Choose..."}; + juce::TextButton rootDefaultBtn_{"Default"}; + std::unique_ptr rootChooser_; + + // Engine switches + juce::Label dspHeading_; + juce::ToggleButton simpleWav_{"Simple WAV only (bypass all DSP)"}; + juce::ToggleButton wind_{"Wind model"}; + juce::ToggleButton tremulant_{"Tremulants"}; + juce::ToggleButton enclosure_{"Enclosures (swell shades)"}; + juce::ToggleButton voicing_{"Voicing adjustments"}; + juce::ToggleButton originalPitch_{"Play at original organ's pitch"}; + + // Bottom buttons + juce::TextButton saveBtn_{"Save Settings"}; + juce::TextButton defaultsBtn_{"Reset to Defaults"}; + juce::TextButton cancelBtn_{"Cancel"}; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(OrganAudioSettingsDialog) +}; + +class OrganDialog : public juce::Component, + public juce::ListBoxModel, + public juce::Timer { +public: + OrganDialog(MasterpieceEditor& editor, MasterpieceProcessor& proc); + ~OrganDialog() override; + + static void show(MasterpieceEditor& editor, MasterpieceProcessor& proc); + + void resized() override; + void paint(juce::Graphics& g) override; + + int getNumRows() override; + void paintListBoxItem(int rowNumber, juce::Graphics& g, int width, int height, + bool rowIsSelected) override; + void listBoxItemDoubleClicked(int row, const juce::MouseEvent&) override; + void selectedRowsChanged(int lastRowSelected) override; + void deleteKeyPressed(int lastRowSelected) override; + void returnKeyPressed(int lastRowSelected) override; + + void timerCallback() override; + +private: + class InstallThread; + std::unique_ptr installThread_; + + void refreshList(); + void updateFilter(); + void loadSelected(); + void adjustAudioSettings(); + void showDetails(); + void removeSelected(); + void performDeleteOrgan(const OrganEntry& entry); + void openOdf(); + void installPackages(); + void startExtraction(const juce::Array& archives); + void cancelInstallation(); + void updateInstallProgress(int currentArchIdx, int totalArchs, const juce::String& archName, + double overallProgress, double subProgress, const juce::String& currentFile); + void installFinished(int succeeded, int total, bool aborted, const juce::String& error); + void closeDialog(); + + MasterpieceEditor& editor_; + MasterpieceProcessor& proc_; + std::vector allOrgans_; + std::vector filteredOrgans_; + + juce::Label titleLabel_; + juce::Label subtitleLabel_; + juce::TextEditor filterBox_; + juce::ListBox listBox_; + + // Selected organ action buttons + juce::TextButton loadBtn_{"Load"}; + juce::TextButton adjustAudioBtn_{"Adjust Audio Settings..."}; + juce::TextButton detailsBtn_{"Details..."}; + juce::TextButton removeBtn_{"Remove..."}; + + // General action buttons + juce::TextButton openOdfBtn_{"Open ODF..."}; + juce::TextButton installBtn_{"Install Organ Packages..."}; + + std::unique_ptr chooser_; + + // Progress overlay panel + struct OverlayPanel : public juce::Component { + OverlayPanel(double& progressRef); + void paint(juce::Graphics& g) override; + void resized() override; + + juce::Label titleLabel; + juce::Label archiveLabel; + juce::Label etaLabel; + juce::ProgressBar progressBar; + juce::Label fileLabel; + juce::TextButton cancelBtn{"Cancel"}; + std::function onCancel; + }; + + double installProgress_ = 0.0; + double installStartTimeMs_ = 0.0; + double installEtaSeconds_ = -1.0; + OverlayPanel installPanel_{installProgress_}; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(OrganDialog) +}; + +} // namespace mp::ui diff --git a/src/mp_ui/Ui.cpp b/src/mp_ui/Ui.cpp index 1fbc976..23d6231 100644 --- a/src/mp_ui/Ui.cpp +++ b/src/mp_ui/Ui.cpp @@ -1,6 +1,7 @@ #include "Ui.h" #include "LoadingDialog.h" +#include "OrganDialog.h" namespace mp::ui { namespace { @@ -257,7 +258,7 @@ void TopBar::setStatus(const juce::String& text) { void TopBar::resized() { auto r = getLocalBounds().reduced(4); - load_.setBounds(r.removeFromLeft(64)); + load_.setBounds(r.removeFromLeft(72)); r.removeFromLeft(6); audio_.setBounds(r.removeFromLeft(64)); r.removeFromLeft(6); @@ -291,10 +292,14 @@ void MasterpieceEditor::chooseAndLoadOrgan() { }); } +void MasterpieceEditor::showOrganDialog() { + OrganDialog::show(*this, proc_); +} + MasterpieceEditor::MasterpieceEditor(MasterpieceProcessor& p) : juce::AudioProcessorEditor(p), proc_(p), - top_(p, [this] { chooseAndLoadOrgan(); }, + top_(p, [this] { showOrganDialog(); }, [this] { if (onAudioSettings) onAudioSettings(); }), console_(p), jamb_(p), @@ -402,6 +407,18 @@ MasterpieceEditor::MasterpieceEditor(MasterpieceProcessor& p) MasterpieceEditor::~MasterpieceEditor() { stopTimer(); } +void MasterpieceEditor::unloadOrgan() { + proc_.unloadOrgan(); + jamb_.rebuild(); + expression_.rebuild(); + console_.rebuild(); + pageTabs_.clearTabs(); + layout_.clear(juce::dontSendNotification); + status_ = "No organ loaded"; + top_.setStatus(status_); + if (onOrganLoaded) onOrganLoaded("No organ loaded"); +} + void MasterpieceEditor::loadOrgan(const juce::File& odf, bool graphicsOnly) { if (loading_) return; // one load at a time; the dialog is the interlock loading_ = true; @@ -427,6 +444,9 @@ void MasterpieceEditor::loadOrgan(const juce::File& odf, bool graphicsOnly) { loadWindow_ = opts.launchAsync(); juce::Thread::launch([this, odf, graphicsOnly] { + if (!graphicsOnly) { + triggerBackgroundAudioStatPrecomputation(odf); + } const auto result = proc_.loadOrgan(odf, /*maxFramesPerSample*/ 0, graphicsOnly); // Everything past here touches components, so it belongs to the message // thread. The lambda copies what it needs; the loader thread ends here. diff --git a/src/mp_ui/Ui.h b/src/mp_ui/Ui.h index 1f5a9ce..c743b2a 100644 --- a/src/mp_ui/Ui.h +++ b/src/mp_ui/Ui.h @@ -87,7 +87,7 @@ class TopBar : public juce::Component { MasterpieceProcessor& proc_; // Terse on purpose: everything the console offers has to share one row, and // a slider that reads out in dB does not also need a label saying "Volume". - juce::TextButton load_{"Open"}; + juce::TextButton load_{"Organs"}; juce::TextButton audio_{"Audio"}; juce::ToggleButton simple_{"No DSP"}; LevelMeter meter_; @@ -111,6 +111,9 @@ class MasterpieceEditor : public juce::AudioProcessorEditor, // draws the console without reading any audio — see // MasterpieceProcessor::loadOrgan. void loadOrgan(const juce::File& odf, bool graphicsOnly = false); + void unloadOrgan(); + // Show the Organs dialog (installed organs list, ODF picker, package installer). + void showOrganDialog(); // Ask for an organ file and load it. Shared with the first-run wizard. void chooseAndLoadOrgan(); // Show one of the organ's console pages, counting from 1. A set with jambs diff --git a/tests/test_core.cpp b/tests/test_core.cpp index 163a0d4..9ab24e1 100644 --- a/tests/test_core.cpp +++ b/tests/test_core.cpp @@ -36,6 +36,7 @@ #include "../src/mp_audio/AudioRecorder.h" #include "../src/mp_audio/SampleLibrary.h" #include "../src/mp_audio/MasterpieceProcessor.h" +#include "../src/mp_ui/OrganDialog.h" #endif #include @@ -5003,6 +5004,388 @@ class MasterGainPersistenceTest final : public mp::test::Test { "repeated saves leave the file the same size"); } }; + +class RecentOrgansTest final : public mp::test::Test { +public: + RecentOrgansTest() + : Test("functional.organs.recent-organs", Category::Functional) {} + void run() override { + const auto dir = mp::MasterpieceProcessor::dataDirectory(); + MP_CHECK(dir.getFullPathName().isNotEmpty(), "data directory must not be empty"); + MP_CHECK(dir.getFileName() == "Masterpiece", "data directory name is Masterpiece"); + + mp::MasterpieceProcessor proc; + const juce::File f1("/path/to/Organ1.Organ_Hauptwerk_xml"); + const juce::File f2("/path/to/Organ2.Organ_Hauptwerk_xml"); + + proc.addRecentOrgan(f1); + proc.addRecentOrgan(f2); + MP_CHECK(proc.recentOrgans().size() >= 2, "recent organs added"); + MP_CHECK(proc.recentOrgans()[0] == f2, "most recent organ is at the front"); + MP_CHECK(proc.recentOrgans()[1] == f1, "earlier organ follows"); + + proc.addRecentOrgan(f1); + MP_CHECK(proc.recentOrgans()[0] == f1, "re-added organ moves to front"); + MP_CHECK(proc.recentOrgans()[1] == f2, "other organ pushed back"); + + proc.removeRecentOrgan(f2); + MP_CHECK(std::find(proc.recentOrgans().begin(), proc.recentOrgans().end(), f2) == + proc.recentOrgans().end(), "removed organ is gone"); + } +}; + +class ArchiveFilteringTest final : public mp::test::Test { +public: + ArchiveFilteringTest() + : Test("functional.organs.archive-filtering", Category::Functional) {} + void run() override { + const auto d = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("mp_archive_test_" + juce::String::toHexString(juce::Random::getSystemRandom().nextInt64())); + d.createDirectory(); + + const auto singleRar = d.getChildFile("SinglePackage.CompPkg_Hauptwerk_rar"); + singleRar.create(); + + const auto p1 = d.getChildFile("MultiSet.part1.rar"); + const auto p2 = d.getChildFile("MultiSet.part2.rar"); + const auto p3 = d.getChildFile("MultiSet.part3.rar"); + p1.create(); + p2.create(); + p3.create(); + + const auto b01 = d.getChildFile("SetB.part01.rar"); + const auto b02 = d.getChildFile("SetB.part02.rar"); + b01.create(); + b02.create(); + + const auto oldRar = d.getChildFile("OldStyle.rar"); + const auto oldR00 = d.getChildFile("OldStyle.r00"); + const auto oldR01 = d.getChildFile("OldStyle.r01"); + oldRar.create(); + oldR00.create(); + oldR01.create(); + + juce::Array allFiles = {singleRar, p1, p2, p3, b01, b02, oldRar, oldR00, oldR01}; + const auto filtered = mp::ui::filterArchivesForExtraction(allFiles); + + MP_CHECK(filtered.size() == 4, "filtered down to primary archives of each set"); + MP_CHECK(filtered.contains(singleRar), "standalone archive kept"); + MP_CHECK(filtered.contains(p1), "part1 kept"); + MP_CHECK(!filtered.contains(p2), "part2 excluded"); + MP_CHECK(!filtered.contains(p3), "part3 excluded"); + MP_CHECK(filtered.contains(b01), "part01 kept"); + MP_CHECK(!filtered.contains(b02), "part02 excluded"); + MP_CHECK(filtered.contains(oldRar), "old style .rar kept"); + MP_CHECK(!filtered.contains(oldR00), ".r00 excluded"); + MP_CHECK(!filtered.contains(oldR01), ".r01 excluded"); + + juce::Array onlyPart2 = {p2}; + const auto resolved = mp::ui::filterArchivesForExtraction(onlyPart2); + MP_CHECK(resolved.size() == 1 && resolved.contains(p1), + "selecting secondary part resolves to part1 if present"); + + d.deleteRecursively(); + } +}; + +class OrganNameParsingTest final : public mp::test::Test { +public: + OrganNameParsingTest() + : Test("functional.organs.name-parsing", Category::Functional) {} + void run() override { + const auto fixturePath = juce::File::getCurrentWorkingDirectory() + .getChildFile("tests") + .getChildFile("minimal.Organ_Hauptwerk_xml"); + if (fixturePath.existsAsFile()) { + const auto name = mp::ui::readOrganNameFromOdf(fixturePath); + MP_CHECK(name == "Masterpiece Test Church", "organ name parsed from Identification_Name"); + } + + const auto d = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("mp_odf_name_test_" + juce::String::toHexString(juce::Random::getSystemRandom().nextInt64())); + d.createDirectory(); + + const auto tempFile = d.getChildFile("Custom.Organ_Hauptwerk_xml"); + tempFile.replaceWithText( + "\n" + "\n" + " \n" + " <_General>\n" + " St. Sulpice Paris\n" + " \n" + " \n" + ""); + const auto parsedName = mp::ui::readOrganNameFromOdf(tempFile); + MP_CHECK(parsedName == "St. Sulpice Paris", "organ name parsed from Identification_OrganName"); + + const auto fallbackFile = d.getChildFile("FallbackName.Organ_Hauptwerk_xml"); + fallbackFile.replaceWithText(""); + const auto fallbackName = mp::ui::readOrganNameFromOdf(fallbackFile); + MP_CHECK(fallbackName == "FallbackName", "falls back to file basename when no name tag exists"); + + d.deleteRecursively(); + } +}; + +class OrganEtaTest final : public mp::test::Test { +public: + OrganEtaTest() + : Test("functional.organs.eta-calculation", Category::Functional) {} + void run() override { + MP_CHECK(mp::ui::humaniseEta(10.0) == "less than a minute", "under 45s is less than a minute"); + MP_CHECK(mp::ui::humaniseEta(44.0) == "less than a minute", "44s is less than a minute"); + MP_CHECK(mp::ui::humaniseEta(45.0) == "about a minute", "45s rounds to about a minute"); + MP_CHECK(mp::ui::humaniseEta(80.0) == "about a minute", "80s rounds to about a minute"); + MP_CHECK(mp::ui::humaniseEta(90.0) == "about 2 minutes", "90s rounds to about 2 minutes"); + MP_CHECK(mp::ui::humaniseEta(150.0) == "about 3 minutes", "150s rounds to about 3 minutes"); + MP_CHECK(mp::ui::humaniseEta(600.0) == "about 10 minutes", "600s is about 10 minutes"); + } +}; + +class UnrarDiscoveryTest final : public mp::test::Test { +public: + UnrarDiscoveryTest() + : Test("functional.organs.unrar-discovery", Category::Functional) {} + void run() override { + const auto unrar = mp::ui::findUnrarBinary(); + if (unrar.existsAsFile()) { + MP_CHECK(unrar.getFileNameWithoutExtension().toLowerCase() == "unrar", + "discovered binary is unrar"); + } + } +}; + +class OrganDiskSpaceFormatTest final : public mp::test::Test { +public: + OrganDiskSpaceFormatTest() + : Test("functional.organs.disk-space-format", Category::Functional) {} + void run() override { + MP_CHECK(mp::ui::formatByteSize(0) == "0 B", "0 bytes formatted"); + MP_CHECK(mp::ui::formatByteSize(512) == "512 B", "512 B formatted"); + MP_CHECK(mp::ui::formatByteSize(1024) == "1.0 KB", "1 KB formatted"); + MP_CHECK(mp::ui::formatByteSize(1572864) == "1.5 MB", "1.5 MB formatted"); + MP_CHECK(mp::ui::formatByteSize(static_cast(2.5 * 1024 * 1024 * 1024)) == "2.50 GB", "2.5 GB formatted"); + } +}; + +class OrganDetailsAndPackagesTest final : public mp::test::Test { +public: + OrganDetailsAndPackagesTest() + : Test("functional.organs.details-and-packages", Category::Functional) {} + void run() override { + const auto fixturePath = juce::File::getCurrentWorkingDirectory() + .getChildFile("tests") + .getChildFile("minimal.Organ_Hauptwerk_xml"); + if (fixturePath.existsAsFile()) { + mp::MasterpieceProcessor proc; + const auto details = mp::ui::getOrganDetails(fixturePath, proc); + MP_CHECK(details.name == "Masterpiece Test Church", "details organ name matches"); + MP_CHECK(details.uniqueOrganId == "90001", "details unique organ ID matches"); + MP_CHECK(details.odfSizeBytes > 0, "ODF size is positive"); + MP_CHECK(details.diskSpaceBytes >= details.odfSizeBytes, "total disk space includes ODF"); + MP_CHECK(details.packages.size() == 1, "found 1 required package"); + if (details.packages.size() == 1) { + MP_CHECK(details.packages[0].packageId == 1, "package ID is 1"); + MP_CHECK(details.packages[0].name == "MinimalTestPackage", "package name is MinimalTestPackage"); + MP_CHECK(details.packages[0].directory.getFileName() == "000001", "directory ends in 000001"); + } + } + } +}; + +class OrganHidingTest final : public mp::test::Test { +public: + OrganHidingTest() + : Test("functional.organs.hidden-organs", Category::Functional) {} + void run() override { + mp::MasterpieceProcessor proc; + const juce::File testOdf("/tmp/dummy_test_organ.Organ_Hauptwerk_xml"); + + MP_CHECK(!proc.isOrganHidden(testOdf), "organ not initially hidden"); + proc.hideOrgan(testOdf); + MP_CHECK(proc.isOrganHidden(testOdf), "organ is hidden after hideOrgan"); + proc.unhideOrgan(testOdf); + MP_CHECK(!proc.isOrganHidden(testOdf), "organ unhidden after unhideOrgan"); + } +}; + +class OrganOfflineAudioSettingsTest final : public mp::test::Test { +public: + OrganOfflineAudioSettingsTest() + : Test("functional.organs.offline-audio-settings", Category::Functional) {} + void run() override { + mp::MasterpieceProcessor proc; + const auto d = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("mp_audio_cfg_test_" + juce::String::toHexString(juce::Random::getSystemRandom().nextInt64())); + d.createDirectory(); + + const auto odf = d.getChildFile("OfflineOrgan.Organ_Hauptwerk_xml"); + odf.create(); + + auto cfg = mp::ui::loadOrganAudioConfig(proc, odf); + MP_CHECK(cfg.storage == mp::SampleStorage::Int24, "default storage is 24-bit"); + MP_CHECK(!cfg.mono, "default mono is false"); + + cfg.storage = mp::SampleStorage::Int16; + cfg.mono = true; + cfg.streamReleases = true; + cfg.sampleRate = 48000.0; + cfg.engineSwitch.simpleWavOnly = true; + + MP_CHECK(mp::ui::saveOrganAudioConfig(proc, odf, cfg), "saveOrganAudioConfig succeeded"); + + const auto loadedCfg = mp::ui::loadOrganAudioConfig(proc, odf); + MP_CHECK(loadedCfg.storage == mp::SampleStorage::Int16, "storage restored as 16-bit"); + + MP_CHECK(loadedCfg.mono, "mono restored as true"); + MP_CHECK(loadedCfg.streamReleases, "stream releases restored as true"); + MP_CHECK(loadedCfg.sampleRate == 48000.0, "sample rate restored as 48000"); + MP_CHECK(loadedCfg.engineSwitch.simpleWavOnly, "simpleWavOnly switch restored"); + + const auto organKey = mp::MasterpieceProcessor::organKeyFor(odf); + const auto organFile = mp::MasterpieceProcessor::dataDirectory().getChildFile("organs").getChildFile(juce::String(organKey) + ".mporgan"); + if (organFile.existsAsFile()) organFile.deleteFile(); + d.deleteRecursively(); + } +}; + +class OrganRamEstimationTest final : public mp::test::Test { +public: + OrganRamEstimationTest() + : Test("functional.organs.ram-estimation", Category::Functional) {} + void run() override { + mp::ui::OrganAudioStat stat; + stat.totalAudioFrames = 1000000; + stat.attackFrames = 600000; + stat.releaseFrames = 400000; + stat.attackLoopFrames = 200000; + stat.attackCount = 10; + stat.releaseCount = 10; + stat.rawPcmBytes = 6000000; + stat.hasStats = true; + + // 24-bit stereo full hold: (600000 + 400000) * 3 * 2 + 128MB overhead + mp::ui::OrganAudioConfig c24; + c24.storage = mp::SampleStorage::Int24; + c24.mono = false; + c24.streamReleases = false; + c24.preloadHeadFrames = 0; + juce::int64 ram24 = mp::ui::estimateRamFootprintBytes(stat, c24); + MP_CHECK(ram24 == (1000000LL * 6LL + 128LL * 1024 * 1024), "24-bit stereo matches formula"); + + // 16-bit stereo full hold: (1000000) * 2 * 2 + 128MB overhead + mp::ui::OrganAudioConfig c16; + c16.storage = mp::SampleStorage::Int16; + c16.mono = false; + c16.streamReleases = false; + c16.preloadHeadFrames = 0; + juce::int64 ram16 = mp::ui::estimateRamFootprintBytes(stat, c16); + MP_CHECK(ram16 == (1000000LL * 4LL + 128LL * 1024 * 1024), "16-bit stereo matches formula"); + + // 16-bit mono full hold: (1000000) * 2 * 1 + 128MB overhead + mp::ui::OrganAudioConfig cMono; + cMono.storage = mp::SampleStorage::Int16; + cMono.mono = true; + cMono.streamReleases = false; + cMono.preloadHeadFrames = 0; + juce::int64 ramMono = mp::ui::estimateRamFootprintBytes(stat, cMono); + MP_CHECK(ramMono == (1000000LL * 2LL + 128LL * 1024 * 1024), "16-bit mono matches formula"); + + // 16-bit stereo stream releases (streamHead=44100): + // resident attack = 600000, resident release = 10 * 44100 = 441000. + // resident frames = 600000 + 441000 = 1041000, but capped at total release frames (400000) -> 600000 + 400000. + // If streamHead = 10000: release = 10 * 10000 = 100000. resident frames = 700000. + mp::ui::OrganAudioConfig cStream; + cStream.storage = mp::SampleStorage::Int16; + cStream.mono = false; + cStream.streamReleases = true; + cStream.streamHeadFrames = 10000; + cStream.preloadHeadFrames = 0; + juce::int64 ramStream = mp::ui::estimateRamFootprintBytes(stat, cStream); + MP_CHECK(ramStream == (700000LL * 4LL + 128LL * 1024 * 1024), "stream releases reduces release RAM"); + + // Check discoverOrgans filters out placeholder "Organ1" + mp::MasterpieceProcessor proc; + proc.addRecentOrgan(juce::File("/nonexistent/path/Organ1.Organ_Hauptwerk_xml")); + auto organs = mp::ui::discoverOrgans(proc); + bool foundDummyOrgan1 = false; + for (const auto& o : organs) { + if (o.name == "Organ1" && !o.exists) { + foundDummyOrgan1 = true; + break; + } + } + MP_CHECK(!foundDummyOrgan1, "discoverOrgans eliminated missing dummy Organ1 entry"); + } +}; + +class OrganAsyncStatScanTest final : public mp::test::Test { +public: + OrganAsyncStatScanTest() + : Test("functional.organs.async-stat-scan", Category::Functional) {} + void run() override { + const auto tempDir = juce::File::getSpecialLocation(juce::File::tempDirectory) + .getChildFile("mp_stat_test_" + juce::String::toHexString(juce::Random::getSystemRandom().nextInt64())); + tempDir.createDirectory(); + + const auto odfFile = tempDir.getChildFile("TestOrgan.Organ_Hauptwerk_xml"); + odfFile.replaceWithText("Test Organ"); + + const auto pipeDir = tempDir.getChildFile("PipeSamples").getChildFile("000001"); + pipeDir.createDirectory(); + + const auto wavFile = pipeDir.getChildFile("test.wav"); + { + juce::WavAudioFormat wavFmt; + std::unique_ptr writer( + wavFmt.createWriterFor(wavFile.createOutputStream().release(), 44100.0, 2, 24, {}, 0)); + if (writer != nullptr) { + juce::AudioBuffer buf(2, 4410); + buf.clear(); + writer->writeFromAudioSampleBuffer(buf, 0, 4410); + } + } + + mp::MasterpieceProcessor proc; + auto entry = mp::ui::getOrganDetails(odfFile, proc); + MP_CHECK(entry.exists, "entry exists"); + + const auto cacheFile = mp::ui::getOrganAudioStatCacheFile(entry); + if (cacheFile.existsAsFile()) cacheFile.deleteFile(); + + MP_CHECK(!mp::ui::hasCachedOrganAudioStat(entry), "hasCachedOrganAudioStat is false before calculation"); + + std::atomic cancelFlag{true}; + auto cancelledStat = mp::ui::computeOrganAudioStat(entry, proc, nullptr, &cancelFlag); + MP_CHECK(!cancelledStat.hasStats, "cancelled stat aborted"); + MP_CHECK(!mp::ui::hasCachedOrganAudioStat(entry), "still not cached after cancel"); + + cancelFlag.store(false); + double reportedProgress = 0.0; + int reportedFiles = 0; + auto stat = mp::ui::computeOrganAudioStat( + entry, proc, + [&](double p, int cur, int tot) { + reportedProgress = p; + reportedFiles = cur; + }, + &cancelFlag); + + MP_CHECK(stat.hasStats, "stat calculation succeeded"); + MP_CHECK(stat.totalAudioFrames == 4410, "scanned 4410 audio frames"); + MP_CHECK(reportedProgress >= 1.0, "progress reached 1.0"); + MP_CHECK(reportedFiles >= 1, "reported at least 1 file"); + MP_CHECK(mp::ui::hasCachedOrganAudioStat(entry), "hasCachedOrganAudioStat is true after scan"); + + auto cachedStat = mp::ui::computeOrganAudioStat(entry, proc); + MP_CHECK(cachedStat.hasStats, "cached stat loaded"); + MP_CHECK(cachedStat.totalAudioFrames == 4410, "cached totalAudioFrames matches"); + + if (cacheFile.existsAsFile()) cacheFile.deleteFile(); + tempDir.deleteRecursively(); + } +}; + #endif // MP_TEST_HAS_AUDIO #ifdef MP_TEST_HAS_DSP @@ -7555,6 +7938,17 @@ class MemoryDefaultsTest final : public mp::test::Test { #ifdef MP_TEST_HAS_AUDIO static SampleLibraryTest g_sampleLibrary; static MemoryDefaultsTest g_memoryDefaults; +static RecentOrgansTest g_recentOrgans; +static ArchiveFilteringTest g_archiveFiltering; +static OrganNameParsingTest g_organNameParsing; +static UnrarDiscoveryTest g_unrarDiscovery; +static OrganEtaTest g_organEta; +static OrganDiskSpaceFormatTest g_organDiskSpace; +static OrganDetailsAndPackagesTest g_organDetails; +static OrganHidingTest g_organHiding; +static OrganOfflineAudioSettingsTest g_organOfflineAudio; +static OrganRamEstimationTest g_organRamEstimation; +static OrganAsyncStatScanTest g_organAsyncStatScan; #endif static DspFastPathTest g_dspFastPath; static EnclosureResponseTest g_encResponse;