From 2ee9a5b3d934c61ac4b0fe8acdf09057d25a32f4 Mon Sep 17 00:00:00 2001 From: Bonni Date: Sat, 19 Sep 2026 23:39:12 -0300 Subject: [PATCH 1/4] Let the sample cache live where the player chooses A decoded cache is as large as the organs played through it, and a machine often has a small fast disk and a large slow one. The cache folder is now a setting, on the Engine tab beside the cache mode, saved as soon as it is chosen. Empty means the default place beside the other settings, which is what every existing installation keeps. A folder that cannot be created -- a drive that is not plugged in -- falls back to the default rather than stopping the load. CI artifacts are also given a seven-day retention. At a few hundred megabytes a run, the default ninety days had grown to seven gigabytes. --- .github/workflows/build.yml | 13 ++++++++ src/mp_audio/MasterpieceProcessor.cpp | 40 +++++++++++++++++++---- src/mp_audio/MasterpieceProcessor.h | 10 ++++++ src/mp_ui/Settings.cpp | 47 +++++++++++++++++++++++++++ src/mp_ui/Settings.h | 6 ++++ 5 files changed, 110 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c3d221e..5b3a363 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -356,6 +356,10 @@ jobs: name: masterpiece-windows-setup path: dist/masterpiece-windows-setup.exe if-no-files-found: error + # A CI build is worth keeping only until someone has looked at it. + # The default is 90 days, and at a few hundred megabytes a run that + # fills the account's storage on its own. + retention-days: 7 - uses: actions/upload-artifact@v4 if: runner.os == 'Linux' @@ -363,6 +367,10 @@ jobs: name: ${{ matrix.artifact }}-deb path: dist/masterpiece-linux-*.deb if-no-files-found: error + # A CI build is worth keeping only until someone has looked at it. + # The default is 90 days, and at a few hundred megabytes a run that + # fills the account's storage on its own. + retention-days: 7 - uses: actions/upload-artifact@v4 if: matrix.mac @@ -370,6 +378,10 @@ jobs: name: ${{ matrix.artifact }} path: dist/${{ matrix.artifact }}.zip if-no-files-found: error + # A CI build is worth keeping only until someone has looked at it. + # The default is 90 days, and at a few hundred megabytes a run that + # fills the account's storage on its own. + retention-days: 7 - uses: actions/upload-artifact@v4 if: '!matrix.mac' @@ -380,3 +392,4 @@ jobs: build/${{ matrix.preset }}/apps/MasterpiecePlugin/**/*.vst3 build/${{ matrix.preset }}/apps/MasterpiecePlugin/**/*.lv2 if-no-files-found: warn + retention-days: 7 diff --git a/src/mp_audio/MasterpieceProcessor.cpp b/src/mp_audio/MasterpieceProcessor.cpp index 3f682b2..3ad65e2 100644 --- a/src/mp_audio/MasterpieceProcessor.cpp +++ b/src/mp_audio/MasterpieceProcessor.cpp @@ -1180,6 +1180,8 @@ bool MasterpieceProcessor::writeGlobalFile() const { text << "reopenlast " << (reopenLastOrgan_ ? 1 : 0) << "\n"; text << "loadticks " << (loadTicks_.load(std::memory_order_acquire) ? 1 : 0) << "\n"; + if (cacheDir_.getFullPathName().isNotEmpty()) + text << "cachedir " << cacheDir_.getFullPathName() << "\n"; if (lastOrgan_.getFullPathName().isNotEmpty()) text << "lastorgan " << lastOrgan_.getFullPathName() << "\n"; @@ -1222,6 +1224,11 @@ bool MasterpieceProcessor::loadGlobalDefaults() { reopenLastOrgan_ = val.getIntValue() != 0; } else if (key == "loadticks") { loadTicks_.store(val.getIntValue() != 0, std::memory_order_release); + } else if (key == "cachedir") { + // A path, taken whole: the sample cache can be gigabytes, and a player + // with a small fast disk and a large slow one wants to choose which of + // them holds it. + cacheDir_ = val.isEmpty() ? juce::File() : juce::File(val); } else if (key == "lastorgan") { lastOrgan_ = juce::File(val); } else if (key == "favourite") { @@ -1292,6 +1299,32 @@ void MasterpieceProcessor::setReopenLastOrgan(bool on) { writeGlobalFile(); } +juce::File MasterpieceProcessor::defaultCacheDirectory() { + return juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) + .getChildFile("Masterpiece") + .getChildFile("cache"); +} + +juce::File MasterpieceProcessor::cacheDirectory() const { + // The folder the player chose, as long as it can be created: a cache on a + // drive that is not plugged in must not stop an organ from loading. It only + // means this load is not cached. + if (cacheDir_.getFullPathName().isNotEmpty()) { + cacheDir_.createDirectory(); + if (cacheDir_.isDirectory()) return cacheDir_; + } + return defaultCacheDirectory(); +} + +void MasterpieceProcessor::setCacheDirectory(const juce::File& dir) { + if (dir == cacheDir_) return; + cacheDir_ = dir; + samples_.setCacheDir(cacheDirectory().getFullPathName().toStdString()); + // Written at once, like the other general preferences: where the cache + // lives is a property of the machine, not of the organ that is open. + writeGlobalFile(); +} + void MasterpieceProcessor::setLoadTicks(bool on) { if (loadTicks_.load(std::memory_order_acquire) == on) return; loadTicks_.store(on, std::memory_order_release); @@ -2738,12 +2771,7 @@ MasterpieceProcessor::LoadResult MasterpieceProcessor::loadOrgan( // What the cache is keyed to: which organ, and whether its definition has // changed since the cache was written. Both are cheap to read and neither // is guessable from the model alone. - samples_.setCacheDir( - juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) - .getChildFile("Masterpiece") - .getChildFile("cache") - .getFullPathName() - .toStdString()); + samples_.setCacheDir(cacheDirectory().getFullPathName().toStdString()); samples_.setCacheIdentity( organKey(), odfFile.getFullPathName().toStdString() + "|" + diff --git a/src/mp_audio/MasterpieceProcessor.h b/src/mp_audio/MasterpieceProcessor.h index ce913bc..61a9829 100644 --- a/src/mp_audio/MasterpieceProcessor.h +++ b/src/mp_audio/MasterpieceProcessor.h @@ -477,6 +477,15 @@ class MasterpieceProcessor : public juce::AudioProcessor { // Audible load progress: a swift tap at each 10% of a load. Off unless // asked. Global, never per organ: it suits the room, not the instrument. bool loadTicks() const { return loadTicks_.load(std::memory_order_acquire); } + + // Where the sample cache is written. A cache is as large as the organs + // played through it, so a machine with a small fast disk and a large slow + // one has to be told which to use. An empty file means the default place, + // beside the other settings; setting one saves the choice at once. + juce::File cacheDirectory() const; + void setCacheDirectory(const juce::File& dir); + static juce::File defaultCacheDirectory(); + juce::File cacheDirectorySetting() const { return cacheDir_; } 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 @@ -894,6 +903,7 @@ class MasterpieceProcessor : public juce::AudioProcessor { int64_t preloadHead_ = 0; bool reopenLastOrgan_ = true; std::atomic loadTicks_{false}; + juce::File cacheDir_; // empty: the default place // Next 10% threshold to tap at, 10 through 100. Reset by whoever starts a // load and advanced by the audio thread, so both sides use an atomic and // neither waits on the other. diff --git a/src/mp_ui/Settings.cpp b/src/mp_ui/Settings.cpp index b14f6f7..eb370ec 100644 --- a/src/mp_ui/Settings.cpp +++ b/src/mp_ui/Settings.cpp @@ -149,6 +149,34 @@ EnginePanel::EnginePanel(MasterpieceProcessor& p) : proc_(p) { : SampleLibrary::CacheMode::Single); }; + // Where that cache is written. It is as large as the organs played through + // it, so a machine with a small fast disk and a large slow one needs to be + // able to say which holds it. + addAndMakeVisible(cacheDirLabel_); + styleLabel(cacheDirLabel_, "Cache folder"); + addAndMakeVisible(cacheDirValue_); + cacheDirValue_.setColour(juce::Label::textColourId, juce::Colours::lightgrey); + showCacheDir(); + addAndMakeVisible(cacheDirChoose_); + cacheDirChoose_.onClick = [this] { + cacheDirChooser_ = std::make_unique( + "Where should the sample cache be written?", proc_.cacheDirectory()); + cacheDirChooser_->launchAsync( + juce::FileBrowserComponent::openMode | + juce::FileBrowserComponent::canSelectDirectories, + [this](const juce::FileChooser& fc) { + const auto dir = fc.getResult(); + if (dir.getFullPathName().isEmpty()) return; + proc_.setCacheDirectory(dir); + showCacheDir(); + }); + }; + addAndMakeVisible(cacheDirDefault_); + cacheDirDefault_.onClick = [this] { + proc_.setCacheDirectory(juce::File()); + showCacheDir(); + }; + addAndMakeVisible(stream_); stream_.setToggleState(proc_.streamReleases(), juce::dontSendNotification); stream_.onClick = [this] { @@ -364,6 +392,17 @@ void EnginePanel::syncProfile() { applyingProfile_ = false; } +// The folder as the player sees it: the chosen one, or the default place +// named as such, since an empty field would look like a missing setting. +void EnginePanel::showCacheDir() { + const auto chosen = proc_.cacheDirectorySetting(); + cacheDirValue_.setText(chosen.getFullPathName().isEmpty() + ? "Default (" + proc_.cacheDirectory().getFullPathName() + ")" + : chosen.getFullPathName(), + juce::dontSendNotification); + cacheDirValue_.setTooltip(proc_.cacheDirectory().getFullPathName()); +} + void EnginePanel::resized() { auto r = getLocalBounds().reduced(12); for (auto* b : {&simpleWav_, &wind_, &tremulant_, &enclosure_, &voicing_, @@ -392,6 +431,14 @@ void EnginePanel::resized() { cacheLabel_.setBounds(cacheRow.removeFromLeft(180)); cache_.setBounds(cacheRow.removeFromLeft(300)); r.removeFromTop(6); + auto cacheDirRow = r.removeFromTop(kRow); + cacheDirLabel_.setBounds(cacheDirRow.removeFromLeft(180)); + cacheDirChoose_.setBounds(cacheDirRow.removeFromRight(90).reduced(0, 2)); + cacheDirRow.removeFromRight(6); + cacheDirDefault_.setBounds(cacheDirRow.removeFromRight(80).reduced(0, 2)); + cacheDirRow.removeFromRight(8); + cacheDirValue_.setBounds(cacheDirRow); + r.removeFromTop(6); stream_.setBounds(r.removeFromTop(kRow)); r.removeFromTop(2); mono_.setBounds(r.removeFromTop(kRow)); diff --git a/src/mp_ui/Settings.h b/src/mp_ui/Settings.h index 5c04a61..25cff9a 100644 --- a/src/mp_ui/Settings.h +++ b/src/mp_ui/Settings.h @@ -74,6 +74,12 @@ class EnginePanel : public juce::Component, private juce::Timer { juce::ComboBox rate_; juce::Label cacheLabel_; juce::ComboBox cache_; + juce::Label cacheDirLabel_; + juce::Label cacheDirValue_; + juce::TextButton cacheDirChoose_{"Choose..."}; + juce::TextButton cacheDirDefault_{"Default"}; + std::unique_ptr cacheDirChooser_; + void showCacheDir(); juce::Label profileLabel_; juce::ComboBox profile_; juce::ToggleButton stream_{"Stream release tails from disk"}; From 60dc1f76468c583397353e18118241f6926a96cd Mon Sep 17 00:00:00 2001 From: Bonni Date: Sat, 19 Sep 2026 23:54:55 -0300 Subject: [PATCH 2/4] Find an organ whose folders are linked in from another tree Reported against 0.5.0 (#12): a set still failed to load when its OrganDefinitions folder was a symbolic link. The search for OrganInstallationPackages now also walks up from both the path as given and the path with its links resolved, which covers a definition that lands several levels below the folder holding the packages. One layout cannot be worked out from the path at all: when the packages sit beside the link and the definition is opened through the resolved path, nothing in that path leads anywhere near them. For that the organ's root can now be named outright, saved with its other settings, and there is a matching --organ-root for the command line. A test builds that layout and shows both halves: the path as given finds the packages, the resolved path cannot, and naming the folder loads it. --- apps/MasterpieceApp/Main.cpp | 21 ++++ src/mp_audio/MasterpieceProcessor.cpp | 14 +++ src/mp_audio/MasterpieceProcessor.h | 8 ++ src/mp_core/OdfLoader.cpp | 36 ++++-- tests/test_core.cpp | 153 ++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 10 deletions(-) diff --git a/apps/MasterpieceApp/Main.cpp b/apps/MasterpieceApp/Main.cpp index 3070bf6..d16fc59 100644 --- a/apps/MasterpieceApp/Main.cpp +++ b/apps/MasterpieceApp/Main.cpp @@ -483,6 +483,27 @@ class MasterpieceApp : public juce::JUCEApplication { proc_->setPreloadStops(std::move(wanted)); } + // --organ-root : where this organ's OrganInstallationPackages is, + // for a layout the definition's own path cannot reveal. + { + const int at = args.indexOf("--organ-root"); + if (at >= 0 && at + 1 < args.size()) + proc_->setOrganRootOverride(juce::File(args[at + 1])); + } + + // --preload-ranks 2,4,14: load exactly these ranks. For organs whose + // stops reach their pipes through pallets, where the drawn stops name no + // ranks and --preload-drawn cannot narrow the load. + { + const int at = args.indexOf("--preload-ranks"); + if (at >= 0 && at + 1 < args.size()) { + std::vector ranks; + for (const auto& s : juce::StringArray::fromTokens(args[at + 1], ",", "")) + if (s.getIntValue() > 0) ranks.push_back(s.getIntValue()); + proc_->setPreloadRanks(std::move(ranks)); + } + } + if (consolePage > 0) { auto* win = win_.get(); // Chained rather than assigned: a take list may already have claimed diff --git a/src/mp_audio/MasterpieceProcessor.cpp b/src/mp_audio/MasterpieceProcessor.cpp index 3ad65e2..701b431 100644 --- a/src/mp_audio/MasterpieceProcessor.cpp +++ b/src/mp_audio/MasterpieceProcessor.cpp @@ -871,6 +871,8 @@ juce::String MasterpieceProcessor::settingsBody() const { text << "stream " << (samples_.streamReleases() ? 1 : 0) << "\n"; text << "streamhead " << juce::String(samples_.streamHeadFrames()) << "\n"; text << "preload " << juce::String(preloadHead_) << "\n"; + if (organRootOverride_.getFullPathName().isNotEmpty()) + text << "root " << organRootOverride_.getFullPathName() << "\n"; text << "simple " << (sw.simpleWavOnly ? 1 : 0) << "\n"; text << "wind " << (sw.enableWindModel ? 1 : 0) << "\n"; text << "tremulant " << (sw.enableTremulant ? 1 : 0) << "\n"; @@ -945,6 +947,9 @@ void MasterpieceProcessor::applySettingsLine(const juce::String& key, else if (key == "stream") samples_.setStreamReleases(on); else if (key == "streamhead") samples_.setStreamHeadFrames(val.getLargeIntValue()); else if (key == "preload") preloadHead_ = val.getLargeIntValue(); + // Where this organ's OrganInstallationPackages actually is, for a layout + // the definition's path cannot reveal. Taken whole: a path may have spaces. + else if (key == "root") organRootOverride_ = val.isEmpty() ? juce::File() : juce::File(val); else if (key == "simple") sw.simpleWavOnly = on; else if (key == "wind") sw.enableWindModel = on; else if (key == "tremulant") sw.enableTremulant = on; @@ -2446,6 +2451,15 @@ MasterpieceProcessor::LoadResult MasterpieceProcessor::loadOrgan( OdfLoader loader; OdfLoader::Options opts; opts.organRootDir = root.getFullPathName().toStdString(); + // A folder the player named for this organ wins over anything derived from + // the definition's own path. Some layouts cannot be worked out from the + // path at all: a link followed on the way in can leave the definition in a + // tree that holds no packages, and only the player knows where they are. + if (organRootOverride_.isDirectory()) { + opts.organRootDir = organRootOverride_.getFullPathName().toStdString(); + juce::Logger::writeToLog("load: organ root set by hand: " + + organRootOverride_.getFullPathName()); + } OrganModel loaded; if (!loader.load(odfFile.getFullPathName().toStdString(), opts, loaded, diff --git a/src/mp_audio/MasterpieceProcessor.h b/src/mp_audio/MasterpieceProcessor.h index 61a9829..7d3904a 100644 --- a/src/mp_audio/MasterpieceProcessor.h +++ b/src/mp_audio/MasterpieceProcessor.h @@ -675,6 +675,13 @@ class MasterpieceProcessor : public juce::AudioProcessor { // out of the same installation packages the audio comes from. const std::string& organRootDir() const { return organRootDir_; } + // Where this organ's OrganInstallationPackages lives, when the definition's + // own path does not lead there -- a folder linked in from another tree, for + // instance. Empty means work it out from the path, which is the usual case. + // Set before loading; saved with the organ's other settings. + juce::File organRootOverride() const { return organRootOverride_; } + void setOrganRootOverride(const juce::File& dir) { organRootOverride_ = dir; } + // Where the engine gets sample audio. Injected rather than owned, so the // preloaded and streaming backing stores share one voice path (ADR-004) and // tests can hand it a synthesised tone. @@ -848,6 +855,7 @@ class MasterpieceProcessor : public juce::AudioProcessor { SwitchNetwork switches_; std::unordered_set engagedSwitches_; std::string organRootDir_; + juce::File organRootOverride_; // A drawstop on the console IS a switch; clicking it must draw the stop, not // merely animate the picture. Built at load so the audio thread never // searches for it. diff --git a/src/mp_core/OdfLoader.cpp b/src/mp_core/OdfLoader.cpp index 554805d..eee54d9 100644 --- a/src/mp_core/OdfLoader.cpp +++ b/src/mp_core/OdfLoader.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -1877,19 +1878,34 @@ std::string deriveOrganRoot(const std::string& odfPath) { const std::filesystem::path logicalRoot = organRootFrom(odf); if (hasInstallationPackages(logicalRoot)) return logicalRoot.string(); - // The logical root has no OrganInstallationPackages sibling -- try again - // with the ODF's own symlinks resolved. A set with OrganDefinitions moved - // onto another drive and linked back in can hand back a path whose plain - // textual parent is no longer where OrganInstallationPackages lives; asking - // the filesystem what the path actually resolves to finds it again. This - // never applies to a set with no installation packages at all: if the - // canonical root does not have one either, the logical answer is kept. + // The logical root has no OrganInstallationPackages sibling. A set whose + // folders are linked in from elsewhere can hand back a path whose textual + // parent is not where the packages live, so the search widens: + // + // * the same path with its symlinks resolved -- OrganDefinitions moved to + // another drive and linked back in resolves to where it really is; + // * the ancestors of both, because a link can land the definition several + // levels below the folder that holds the packages. + // + // A set that genuinely has no packages keeps the logical answer, so nothing + // about a loose ODF changes. std::error_code ec; + std::vector starts{logicalRoot}; const std::filesystem::path canonicalOdf = std::filesystem::weakly_canonical(odf, ec); - if (!ec && canonicalOdf != odf) { - const std::filesystem::path canonicalRoot = organRootFrom(canonicalOdf); - if (hasInstallationPackages(canonicalRoot)) return canonicalRoot.string(); + if (!ec && canonicalOdf != odf) starts.push_back(organRootFrom(canonicalOdf)); + + // Four levels is past any layout we have seen and stops well short of a + // drive's root, where a stray folder of that name would be someone else's. + constexpr int kMaxAncestors = 4; + for (const auto& start : starts) { + std::filesystem::path dir = start; + for (int up = 0; up <= kMaxAncestors; ++up) { + if (hasInstallationPackages(dir)) return dir.string(); + const std::filesystem::path parent = dir.parent_path(); + if (parent.empty() || parent == dir) break; + dir = parent; + } } return logicalRoot.string(); } diff --git a/tests/test_core.cpp b/tests/test_core.cpp index a5c2506..163a0d4 100644 --- a/tests/test_core.cpp +++ b/tests/test_core.cpp @@ -243,6 +243,113 @@ class PalletSwitchTest final : public mp::test::Test { } }; +#ifdef MP_TEST_HAS_AUDIO +#include "../src/mp_ui/BmpImage.h" + +// Console artwork in BMP. JUCE reads PNG, JPEG and GIF; the older Hauptwerk +// sets paint their consoles in BMP, and those came out black (issue #24). +// The depths and layouts checked here are the ones such a set uses. +class BmpImageTest final : public mp::test::Test { +public: + BmpImageTest() : Test("functional.ui.bmp-artwork", Category::Functional) {} + + void run() override { + namespace fs = std::filesystem; + std::error_code ec; + const fs::path dir = fs::temp_directory_path(ec) / "mp_bmp_test_51c7"; + if (ec) return; + fs::remove_all(dir, ec); + fs::create_directories(dir, ec); + if (ec) return; + struct Cleanup { fs::path p; ~Cleanup(){ std::error_code e; std::filesystem::remove_all(p,e);} } cleanup{dir}; + + // Red, green, blue and white across a four-pixel row. + const std::vector> want = { + {255, 0, 0}, {0, 255, 0}, {0, 0, 255}, {255, 255, 255}}; + + check(dir / "b24.bmp", write24(dir / "b24.bmp"), want, 24); + check(dir / "b32.bmp", write32(dir / "b32.bmp", 255), want, 32); + // Alpha bytes left at zero: the image must still be visible. + check(dir / "b32z.bmp", write32(dir / "b32z.bmp", 0), want, 32); + check(dir / "b8.bmp", write8(dir / "b8.bmp"), want, 8); + check(dir / "btd.bmp", writeTopDown(dir / "btd.bmp"), want, -24); + + // Not a BMP at all: an invalid image, not a crash and not a guess. + const auto junk = dir / "junk.bmp"; + { std::ofstream f(junk, std::ios::binary); f << "not a bitmap at all"; } + MP_CHECK(!mp::loadBmpImage(juce::File(junk.string())).isValid(), + "a file that is not a BMP gives an invalid image"); + } + +private: + static void put32(std::vector& v, uint32_t x) { + v.push_back((uint8_t)(x & 0xff)); v.push_back((uint8_t)((x >> 8) & 0xff)); + v.push_back((uint8_t)((x >> 16) & 0xff)); v.push_back((uint8_t)((x >> 24) & 0xff)); + } + static void put16(std::vector& v, uint16_t x) { + v.push_back((uint8_t)(x & 0xff)); v.push_back((uint8_t)((x >> 8) & 0xff)); + } + static void writeFile(const std::filesystem::path& p, const std::vector& v) { + std::ofstream f(p, std::ios::binary); + f.write(reinterpret_cast(v.data()), (std::streamsize) v.size()); + } + // One row of four pixels, so padding is exercised only where it matters. + static std::vector header(int w, int h, int bpp, size_t dataSize, int palette = 0) { + std::vector v; + const uint32_t off = 14 + 40 + (uint32_t)(palette * 4); + v.push_back('B'); v.push_back('M'); + put32(v, (uint32_t)(off + dataSize)); put16(v, 0); put16(v, 0); put32(v, off); + put32(v, 40); put32(v, (uint32_t) w); put32(v, (uint32_t) h); + put16(v, 1); put16(v, (uint16_t) bpp); put32(v, 0); put32(v, (uint32_t) dataSize); + put32(v, 2835); put32(v, 2835); put32(v, (uint32_t) palette); put32(v, 0); + return v; + } + static bool write24(const std::filesystem::path& p) { + std::vector rows{0,0,255, 0,255,0, 255,0,0, 255,255,255}; + auto v = header(4, 1, 24, rows.size()); + v.insert(v.end(), rows.begin(), rows.end()); + writeFile(p, v); return true; + } + static bool write32(const std::filesystem::path& p, int alpha) { + std::vector rows{0,0,255,(uint8_t)alpha, 0,255,0,(uint8_t)alpha, + 255,0,0,(uint8_t)alpha, 255,255,255,(uint8_t)alpha}; + auto v = header(4, 1, 32, rows.size()); + v.insert(v.end(), rows.begin(), rows.end()); + writeFile(p, v); return true; + } + static bool write8(const std::filesystem::path& p) { + auto v = header(4, 1, 8, 4, 4); + const uint8_t pal[16] = {0,0,255,0, 0,255,0,0, 255,0,0,0, 255,255,255,0}; + v.insert(v.end(), pal, pal + 16); + const uint8_t rows[4] = {0, 1, 2, 3}; + v.insert(v.end(), rows, rows + 4); + writeFile(p, v); return true; + } + static bool writeTopDown(const std::filesystem::path& p) { + std::vector rows{0,0,255, 0,255,0, 255,0,0, 255,255,255}; + auto v = header(4, -1, 24, rows.size()); + v.insert(v.end(), rows.begin(), rows.end()); + writeFile(p, v); return true; + } + void check(const std::filesystem::path& p, bool written, + const std::vector>& want, int what) { + if (!written) return; + const juce::Image img = mp::loadBmpImage(juce::File(p.string())); + const std::string tag = std::to_string(what) + "-bit"; + MP_CHECK(img.isValid() && img.getWidth() == 4, + tag + ": the bitmap loads at its stated size"); + if (!img.isValid()) return; + for (int x = 0; x < 4; ++x) { + const juce::Colour c = img.getPixelAt(x, 0); + MP_CHECK(c.getRed() == want[(size_t) x][0] && c.getGreen() == want[(size_t) x][1] && + c.getBlue() == want[(size_t) x][2] && c.getAlpha() == 255, + tag + ": pixel " + std::to_string(x) + " keeps its colour, opaque"); + } + } +}; + +#endif // MP_TEST_HAS_AUDIO + class EncryptedDetectionTest final : public mp::test::Test { public: EncryptedDetectionTest() @@ -5655,6 +5762,7 @@ class SymlinkedOrganTest final : public mp::test::Test { layoutPackagesFolderSymlinked(base, xml); layoutSinglePackageSymlinked(base, xml); layoutDefinitionsFolderSymlinked(base, xml); + layoutDefinitionsLinkedOutOfTree(base, xml); } private: @@ -5739,6 +5847,48 @@ class SymlinkedOrganTest final : public mp::test::Test { // looking for is simply not there anymore. deriveOrganRoot has to notice // that the root implied by the given path has no OrganInstallationPackages // and fall back to the path's own resolved (canonical) form, which does. + // Layout D, reported against 0.5.0: OrganDefinitions is a link whose TARGET + // lives in a tree of its own, and the packages sit beside the link rather + // than beside the target. Opened through the link the path leads to the + // packages; opened through the resolved path -- which is what a file + // chooser can hand back -- nothing in the path leads anywhere near them. + // No search of the path can find them, so this is what the organ root + // setting is for. + void layoutDefinitionsLinkedOutOfTree(const std::filesystem::path& base, + const std::string& xml) { + namespace fs = std::filesystem; + const fs::path setRoot = base / "layoutD-set"; + const fs::path defsElsewhere = base / "layoutD-defs"; + writeFile(defsElsewhere / "test.Organ_Hauptwerk_xml", xml); + writeFile(setRoot / "OrganInstallationPackages" / "000001" / "001-C.wav", "x"); + writeFile(setRoot / "OrganInstallationPackages" / "000001" / "001-C_Trem.wav", "x"); + if (!trySymlinkDir(defsElsewhere, setRoot / "OrganDefinitions")) return; + + // Through the link, the plain parent walk already lands on the set. + const fs::path throughLink = + setRoot / "OrganDefinitions" / "test.Organ_Hauptwerk_xml"; + std::error_code ec; + MP_CHECK(fs::equivalent(mp::deriveOrganRoot(throughLink.string()), setRoot, ec) && !ec, + "layout D: the path as given leads to the packages"); + + // Through the resolved path it cannot, and must not invent one. + const fs::path resolved = defsElsewhere / "test.Organ_Hauptwerk_xml"; + const std::string derived = mp::deriveOrganRoot(resolved.string()); + MP_CHECK(!fs::is_directory(fs::path(derived) / "OrganInstallationPackages", ec), + "layout D: a resolved path genuinely has no packages to find"); + + // Naming the root is what loads it. + mp::OdfLoader l; + mp::OrganModel m; + mp::OdfDiagnostics d; + mp::OdfLoader::Options o; + o.organRootDir = setRoot.string(); + MP_CHECK(l.loadFromXmlString(xml, "test.Organ_Hauptwerk_xml", o, m, d), + "layout D: set must load with the root named"); + MP_CHECK(d.missingSampleFiles.empty(), + "layout D: samples must be found under the named root"); + } + void layoutDefinitionsFolderSymlinked(const std::filesystem::path& base, const std::string& xml) { namespace fs = std::filesystem; @@ -7272,6 +7422,9 @@ static LoaderRejectsUnknownTest g_rejectUnknown; static LoaderToleranceTest g_tolerance; static LoaderEmptyTableTest g_emptyTable; static PalletSwitchTest g_palletSwitch; +#ifdef MP_TEST_HAS_AUDIO +static BmpImageTest g_bmpImage; +#endif static ConditionSenseTest g_conditionSense; static EncryptedDetectionTest g_encrypted; static FixtureCorpusTest g_fixtures; From 2c256efcabbbd905843497cbc0ee9d90d773325c Mon Sep 17 00:00:00 2001 From: Bonni Date: Sat, 19 Sep 2026 23:54:55 -0300 Subject: [PATCH 3/4] Let the Engine tab scroll, and put the organ folder on it Reported in #21: on macOS the settings window opens too short for the Engine tab, and the rows at the bottom cannot be reached without resizing it by hand. The tab now scrolls when the window is shorter than its contents. The tab also gains the organ folder control, for a set whose packages the definition's path does not lead to. --- src/mp_ui/Settings.cpp | 53 +++++++++++++++++++++++++++++++++++++++++- src/mp_ui/Settings.h | 29 +++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/mp_ui/Settings.cpp b/src/mp_ui/Settings.cpp index eb370ec..1140ff4 100644 --- a/src/mp_ui/Settings.cpp +++ b/src/mp_ui/Settings.cpp @@ -177,6 +177,38 @@ EnginePanel::EnginePanel(MasterpieceProcessor& p) : proc_(p) { showCacheDir(); }; + // Where this organ's OrganInstallationPackages is. Normally worked out + // from the definition's path; a set whose folders are linked in from + // another tree can leave that path pointing nowhere near the audio, and + // then only the player knows. Takes effect the next time it loads. + addAndMakeVisible(organRootLabel_); + styleLabel(organRootLabel_, "Organ folder"); + addAndMakeVisible(organRootValue_); + organRootValue_.setColour(juce::Label::textColourId, juce::Colours::lightgrey); + showOrganRoot(); + addAndMakeVisible(organRootChoose_); + organRootChoose_.onClick = [this] { + organRootChooser_ = std::make_unique( + "Which folder holds OrganInstallationPackages?", + juce::File(proc_.organRootDir())); + organRootChooser_->launchAsync( + juce::FileBrowserComponent::openMode | + juce::FileBrowserComponent::canSelectDirectories, + [this](const juce::FileChooser& fc) { + const auto dir = fc.getResult(); + if (dir.getFullPathName().isEmpty()) return; + proc_.setOrganRootOverride(dir); + proc_.markSettingsDirty(); + showOrganRoot(); + }); + }; + addAndMakeVisible(organRootDefault_); + organRootDefault_.onClick = [this] { + proc_.setOrganRootOverride(juce::File()); + proc_.markSettingsDirty(); + showOrganRoot(); + }; + addAndMakeVisible(stream_); stream_.setToggleState(proc_.streamReleases(), juce::dontSendNotification); stream_.onClick = [this] { @@ -403,6 +435,17 @@ void EnginePanel::showCacheDir() { cacheDirValue_.setTooltip(proc_.cacheDirectory().getFullPathName()); } +// The folder as the player sees it: the one they named, or the one derived +// from the definition's path, marked as derived so the two cannot be +// confused. +void EnginePanel::showOrganRoot() { + const auto chosen = proc_.organRootOverride(); + organRootValue_.setText(chosen.getFullPathName().isEmpty() + ? "From the definition (" + juce::String(proc_.organRootDir()) + ")" + : chosen.getFullPathName() + " -- loads next time", + juce::dontSendNotification); +} + void EnginePanel::resized() { auto r = getLocalBounds().reduced(12); for (auto* b : {&simpleWav_, &wind_, &tremulant_, &enclosure_, &voicing_, @@ -439,6 +482,14 @@ void EnginePanel::resized() { cacheDirRow.removeFromRight(8); cacheDirValue_.setBounds(cacheDirRow); r.removeFromTop(6); + auto organRootRow = r.removeFromTop(kRow); + organRootLabel_.setBounds(organRootRow.removeFromLeft(180)); + organRootChoose_.setBounds(organRootRow.removeFromRight(90).reduced(0, 2)); + organRootRow.removeFromRight(6); + organRootDefault_.setBounds(organRootRow.removeFromRight(80).reduced(0, 2)); + organRootRow.removeFromRight(8); + organRootValue_.setBounds(organRootRow); + r.removeFromTop(6); stream_.setBounds(r.removeFromTop(kRow)); r.removeFromTop(2); mono_.setBounds(r.removeFromTop(kRow)); @@ -1826,7 +1877,7 @@ SettingsWindow::SettingsWindow(MasterpieceProcessor& p, : engine_(p), reverb_(p), metronome_(p), recorder_(p), midi_(p, devices), mixer_(p), voicing_(p), favourites_(p), display_(p) { const auto bg = juce::Colour(0xff1b1e24); addAndMakeVisible(tabs_); - tabs_.addTab("Engine", bg, &engine_, false); + tabs_.addTab("Engine", bg, &engineScroll_, false); tabs_.addTab("Room", bg, &reverb_, false); tabs_.addTab("Metronome", bg, &metronome_, false); tabs_.addTab("Recorder", bg, &recorder_, false); diff --git a/src/mp_ui/Settings.h b/src/mp_ui/Settings.h index 25cff9a..c0baeb4 100644 --- a/src/mp_ui/Settings.h +++ b/src/mp_ui/Settings.h @@ -16,6 +16,27 @@ namespace mp::ui { // Engine: DSP switches and how much of each sample is preloaded. +// A tab that scrolls when the window is too short for its contents. The +// Engine tab has more in it than fits a small window -- reported on macOS, +// where the default window left the last rows off the bottom with no way to +// reach them but resizing. +class ScrollHost : public juce::Viewport { +public: + explicit ScrollHost(juce::Component& c, int minHeight) : minHeight_(minHeight) { + setViewedComponent(&c, false); + setScrollBarsShown(true, false); + } + void resized() override { + juce::Viewport::resized(); + if (auto* c = getViewedComponent()) + c->setSize(getMaximumVisibleWidth(), + juce::jmax(minHeight_, getMaximumVisibleHeight())); + } + +private: + int minHeight_; +}; + class EnginePanel : public juce::Component, private juce::Timer { public: explicit EnginePanel(MasterpieceProcessor& p); @@ -80,6 +101,12 @@ class EnginePanel : public juce::Component, private juce::Timer { juce::TextButton cacheDirDefault_{"Default"}; std::unique_ptr cacheDirChooser_; void showCacheDir(); + juce::Label organRootLabel_; + juce::Label organRootValue_; + juce::TextButton organRootChoose_{"Choose..."}; + juce::TextButton organRootDefault_{"Default"}; + std::unique_ptr organRootChooser_; + void showOrganRoot(); juce::Label profileLabel_; juce::ComboBox profile_; juce::ToggleButton stream_{"Stream release tails from disk"}; @@ -351,6 +378,8 @@ class SettingsWindow : public juce::Component { private: juce::TabbedComponent tabs_{juce::TabbedButtonBar::TabsAtTop}; EnginePanel engine_; + // Tall enough for every row of the Engine tab; see ScrollHost. + ScrollHost engineScroll_{engine_, 720}; ReverbPanel reverb_; MetronomePanel metronome_; RecorderPanel recorder_; From ecf6e82dbf00d01a726fca92cd440a365ddee4f2 Mon Sep 17 00:00:00 2001 From: Bonni Date: Sat, 19 Sep 2026 23:54:55 -0300 Subject: [PATCH 4/4] Draw console artwork stored as BMP Reported in #24: a set whose jambs, keys and drawstops are BMP files drew black, and converting the same files to PNG fixed it. JUCE reads PNG, JPEG and GIF; Hauptwerk predates all three as a console format, and the older sets are painted in BMP. Adds a reader for the shapes those sets use: 1, 4, 8, 16, 24 and 32 bits per pixel, uncompressed or RLE, bottom-up or top-down, honouring the colour masks a BI_BITFIELDS header declares. A 32-bit file whose alpha bytes were never written is treated as opaque rather than drawn as nothing. The console tries JUCE first and falls back to this, so nothing changes for a set that ships PNG. --- src/mp_audio/CMakeLists.txt | 2 + src/mp_ui/BmpImage.cpp | 266 ++++++++++++++++++++++++++++++++++++ src/mp_ui/BmpImage.h | 25 ++++ src/mp_ui/Console.cpp | 7 +- 4 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 src/mp_ui/BmpImage.cpp create mode 100644 src/mp_ui/BmpImage.h diff --git a/src/mp_audio/CMakeLists.txt b/src/mp_audio/CMakeLists.txt index a0f06a3..172e5cc 100644 --- a/src/mp_audio/CMakeLists.txt +++ b/src/mp_audio/CMakeLists.txt @@ -15,6 +15,8 @@ add_library(mp_audio STATIC Convolver.h Convolver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../mp_ui/Ui.h ${CMAKE_CURRENT_SOURCE_DIR}/../mp_ui/Ui.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../mp_ui/BmpImage.h + ${CMAKE_CURRENT_SOURCE_DIR}/../mp_ui/BmpImage.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../mp_ui/Console.h ${CMAKE_CURRENT_SOURCE_DIR}/../mp_ui/Console.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../mp_ui/Settings.h diff --git a/src/mp_ui/BmpImage.cpp b/src/mp_ui/BmpImage.cpp new file mode 100644 index 0000000..09cae7a --- /dev/null +++ b/src/mp_ui/BmpImage.cpp @@ -0,0 +1,266 @@ +#include "BmpImage.h" + +#include +#include + +namespace mp { +namespace { + +struct Reader { + const uint8_t* p = nullptr; + size_t size = 0; + bool ok = true; + + uint32_t u32(size_t at) { + if (at + 4 > size) { ok = false; return 0; } + return static_cast(p[at]) | (static_cast(p[at + 1]) << 8) | + (static_cast(p[at + 2]) << 16) | + (static_cast(p[at + 3]) << 24); + } + uint16_t u16(size_t at) { + if (at + 2 > size) { ok = false; return 0; } + return static_cast(p[at] | (p[at + 1] << 8)); + } +}; + +// Where a colour mask puts its bits, as a shift and a scale back to 0..255. +struct Channel { + int shift = 0; + uint32_t mask = 0; + int bits = 0; + + static Channel from(uint32_t mask) { + Channel c; + c.mask = mask; + if (mask == 0) return c; + while (((mask >> c.shift) & 1u) == 0) ++c.shift; + uint32_t m = mask >> c.shift; + while (m & 1u) { ++c.bits; m >>= 1; } + return c; + } + uint8_t value(uint32_t pixel) const { + if (mask == 0 || bits == 0) return 0; + const uint32_t v = (pixel & mask) >> shift; + if (bits == 8) return static_cast(v); + // Spread the range rather than shifting: 5 bits of 31 is white, not 248. + return static_cast((v * 255u) / ((1u << bits) - 1u)); + } +}; + +// RLE4 and RLE8, which is how the older sets store their smaller artwork. +// Rows are written bottom-up; absolute runs are word-aligned. +bool decodeRle(const uint8_t* src, size_t len, int width, int height, int bpp, + std::vector& indices) { + indices.assign(static_cast(width) * static_cast(height), 0); + int x = 0, y = 0; + size_t i = 0; + auto put = [&](int value) { + if (x < width && y < height) + indices[static_cast(y) * static_cast(width) + + static_cast(x)] = static_cast(value); + ++x; + }; + while (i + 1 < len) { + const uint8_t count = src[i], val = src[i + 1]; + i += 2; + if (count > 0) { + for (int n = 0; n < count; ++n) + put(bpp == 8 ? val : ((n % 2 == 0) ? (val >> 4) : (val & 0x0f))); + continue; + } + if (val == 0) { x = 0; ++y; continue; } // end of line + if (val == 1) return true; // end of bitmap + if (val == 2) { // delta + if (i + 1 >= len) return false; + x += src[i]; y += src[i + 1]; i += 2; + continue; + } + // Absolute mode: `val` pixels follow, padded to a word boundary. + const int n = val; + if (bpp == 8) { + if (i + static_cast(n) > len) return false; + for (int k = 0; k < n; ++k) put(src[i + static_cast(k)]); + i += static_cast(n); + if (n & 1) ++i; + } else { + const size_t bytes = static_cast((n + 1) / 2); + if (i + bytes > len) return false; + for (int k = 0; k < n; ++k) { + const uint8_t b = src[i + static_cast(k / 2)]; + put((k % 2 == 0) ? (b >> 4) : (b & 0x0f)); + } + i += bytes; + if (bytes & 1) ++i; + } + } + return true; +} + +} // namespace + +juce::Image loadBmpImage(const juce::File& file) { + juce::MemoryBlock data; + if (!file.loadFileAsData(data)) return {}; + Reader r{static_cast(data.getData()), data.getSize(), true}; + if (r.size < 54) return {}; + if (r.p[0] != 'B' || r.p[1] != 'M') return {}; + + const uint32_t pixelOffset = r.u32(10); + const uint32_t headerSize = r.u32(14); + if (!r.ok || headerSize < 12) return {}; + + int width = 0, height = 0, bpp = 0; + uint32_t compression = 0, paletteCount = 0; + if (headerSize == 12) { // BITMAPCOREHEADER + width = static_cast(r.u16(18)); + height = static_cast(r.u16(20)); + bpp = r.u16(24); + } else { + width = static_cast(r.u32(18)); + height = static_cast(r.u32(22)); + bpp = r.u16(28); + compression = r.u32(30); + paletteCount = r.u32(46); + } + if (!r.ok || width <= 0 || height == 0) return {}; + const bool topDown = height < 0; + if (topDown) height = -height; + if (width > 20000 || height > 20000) return {}; + + Channel cr, cg, cb, ca; + if (compression == 3 || compression == 6) { // BI_BITFIELDS / BI_ALPHABITFIELDS + const size_t at = 14 + headerSize; + if (headerSize >= 52) { + cr = Channel::from(r.u32(54)); cg = Channel::from(r.u32(58)); + cb = Channel::from(r.u32(62)); + if (headerSize >= 56) ca = Channel::from(r.u32(66)); + } else { + cr = Channel::from(r.u32(at)); cg = Channel::from(r.u32(at + 4)); + cb = Channel::from(r.u32(at + 8)); + } + } else if (bpp == 16) { + cr = Channel::from(0x7c00); cg = Channel::from(0x03e0); cb = Channel::from(0x001f); + } else if (bpp == 32) { + cr = Channel::from(0x00ff0000); cg = Channel::from(0x0000ff00); + cb = Channel::from(0x000000ff); ca = Channel::from(0xff000000); + } + if (!r.ok) return {}; + + // The palette, for the indexed depths. + std::vector palette; + if (bpp <= 8) { + const size_t entrySize = (headerSize == 12) ? 3 : 4; + size_t count = paletteCount != 0 ? paletteCount : (size_t{1} << bpp); + count = juce::jmin(count, 256); + const size_t at = 14 + headerSize; + palette.reserve(count); + for (size_t i = 0; i < count; ++i) { + const size_t e = at + i * entrySize; + if (e + 2 >= r.size) break; + palette.push_back(juce::Colour(r.p[e + 2], r.p[e + 1], r.p[e])); + } + if (palette.empty()) return {}; + } + + if (pixelOffset >= r.size) return {}; + const uint8_t* bits = r.p + pixelOffset; + const size_t bitsLen = r.size - pixelOffset; + + // Decoded into a buffer first: whether the file's alpha channel was ever + // written can only be known once every pixel has been read, and an image + // being written to must not be read back from. + std::vector pixels(static_cast(width) * static_cast(height), + juce::Colours::black); + bool anyVisible = false; + bool sawAlpha = false; + + auto setPixel = [&](int x, int yStored, juce::Colour c) { + const int y = topDown ? yStored : (height - 1 - yStored); + if (x < 0 || x >= width || y < 0 || y >= height) return; + pixels[static_cast(y) * static_cast(width) + static_cast(x)] = c; + if (c.getAlpha() != 0) anyVisible = true; + }; + + auto finish = [&]() { + // A 32-bit file whose alpha bytes were never written reads as fully + // transparent, which would draw nothing at all. Such a file means opaque. + const bool dropAlpha = sawAlpha && !anyVisible; + juce::Image img(juce::Image::ARGB, width, height, true); + juce::Image::BitmapData out(img, juce::Image::BitmapData::writeOnly); + for (int y = 0; y < height; ++y) + for (int x = 0; x < width; ++x) { + const juce::Colour c = + pixels[static_cast(y) * static_cast(width) + static_cast(x)]; + out.setPixelColour(x, y, dropAlpha ? c.withAlpha((juce::uint8) 255) : c); + } + return img; + }; + + if (compression == 1 || compression == 2) { // RLE8 / RLE4 + std::vector indices; + if (!decodeRle(bits, bitsLen, width, height, compression == 1 ? 8 : 4, indices)) + return {}; + for (int y = 0; y < height; ++y) + for (int x = 0; x < width; ++x) { + const uint8_t idx = indices[static_cast(y) * static_cast(width) + + static_cast(x)]; + setPixel(x, y, idx < palette.size() ? palette[idx] : juce::Colours::black); + } + return finish(); + } + + // Uncompressed: rows are padded to four bytes. + const size_t rowBytes = ((static_cast(width) * static_cast(bpp) + 31) / 32) * 4; + if (rowBytes == 0 || bitsLen < rowBytes) return {}; + const int rows = juce::jmin(height, static_cast(bitsLen / rowBytes)); + + for (int y = 0; y < rows; ++y) { + const uint8_t* row = bits + static_cast(y) * rowBytes; + for (int x = 0; x < width; ++x) { + juce::Colour c; + switch (bpp) { + case 1: case 4: case 8: { + const int perByte = 8 / bpp; + const uint8_t b = row[static_cast(x / perByte)]; + const int shift = 8 - bpp * ((x % perByte) + 1); + const size_t idx = static_cast((b >> shift) & ((1 << bpp) - 1)); + c = idx < palette.size() ? palette[idx] : juce::Colours::black; + break; + } + case 16: { + const uint32_t v = static_cast(row[x * 2] | (row[x * 2 + 1] << 8)); + c = juce::Colour(cr.value(v), cg.value(v), cb.value(v)); + break; + } + case 24: { + const uint8_t* q = row + static_cast(x) * 3; + c = juce::Colour(q[2], q[1], q[0]); + break; + } + case 32: { + const uint8_t* q = row + static_cast(x) * 4; + const uint32_t v = static_cast(q[0]) | (static_cast(q[1]) << 8) | + (static_cast(q[2]) << 16) | + (static_cast(q[3]) << 24); + sawAlpha = ca.mask != 0; + const uint8_t a = ca.mask != 0 ? ca.value(v) : 255; + c = juce::Colour(cr.value(v), cg.value(v), cb.value(v)).withAlpha(a); + break; + } + default: + return {}; + } + setPixel(x, y, c); + } + } + + return finish(); +} + +juce::Image loadConsoleImage(const juce::File& file) { + juce::Image img = juce::ImageFileFormat::loadFrom(file); + if (img.isValid()) return img; + return loadBmpImage(file); +} + +} // namespace mp diff --git a/src/mp_ui/BmpImage.h b/src/mp_ui/BmpImage.h new file mode 100644 index 0000000..853568d --- /dev/null +++ b/src/mp_ui/BmpImage.h @@ -0,0 +1,25 @@ +// Console artwork stored as Windows BMP. +// +// JUCE reads PNG, JPEG and GIF. Hauptwerk predates all three as a console +// format: the older sets paint their jambs, keys and drawstops in BMP, and +// asking JUCE for one returns an invalid image, which draws as black. Issue +// #24 is a set that renders correctly only once its bitmaps are converted. +// +// This reads the shapes those sets actually use: 1, 4, 8, 16, 24 and 32 bits +// per pixel, uncompressed or RLE4/RLE8, stored bottom-up or top-down, with +// the colour masks a BI_BITFIELDS header declares. 32-bit files carry an +// alpha channel only sometimes, so it is used when any pixel is not opaque +// and ignored when every one of them is zero -- a file whose alpha bytes are +// unwritten must not come out invisible. +#pragma once +#include + +namespace mp { + +// The image, or an invalid image if the file is not a BMP this can read. +juce::Image loadBmpImage(const juce::File& file); + +// Any image the console asks for: JUCE's own formats first, then BMP. +juce::Image loadConsoleImage(const juce::File& file); + +} // namespace mp diff --git a/src/mp_ui/Console.cpp b/src/mp_ui/Console.cpp index d4ee8d4..45db8a1 100644 --- a/src/mp_ui/Console.cpp +++ b/src/mp_ui/Console.cpp @@ -1,5 +1,7 @@ #include "Console.h" +#include "BmpImage.h" + #include "../mp_core/KeyboardLayout.h" #include @@ -553,7 +555,7 @@ const juce::Image* ConsoleView::imageFor(Id imageSetId, int index) { const auto path = resolveIgnoringCase( resolveBitmap(organRoot_, element->bitmapFile, set.packageId)); - juce::Image img = juce::ImageFileFormat::loadFrom(juce::File(path.string())); + juce::Image img = loadConsoleImage(juce::File(path.string())); // Hauptwerk predates transparent bitmaps: a set that ships BMPs carries a // separate mask image instead, black where the artwork shows and white where @@ -562,8 +564,7 @@ const juce::Image* ConsoleView::imageFor(Id imageSetId, int index) { if (img.isValid() && !set.transparencyMaskFile.empty()) { const auto maskPath = resolveIgnoringCase( resolveBitmap(organRoot_, set.transparencyMaskFile, set.packageId)); - juce::Image mask = - juce::ImageFileFormat::loadFrom(juce::File(maskPath.string())); + juce::Image mask = loadConsoleImage(juce::File(maskPath.string())); if (mask.isValid()) { img = img.convertedToFormat(juce::Image::ARGB); const int w = std::min(img.getWidth(), mask.getWidth());