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/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/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_audio/MasterpieceProcessor.cpp b/src/mp_audio/MasterpieceProcessor.cpp
index 3f682b2..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;
@@ -1180,6 +1185,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 +1229,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 +1304,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);
@@ -2413,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,
@@ -2738,12 +2785,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..7d3904a 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
@@ -666,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.
@@ -839,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.
@@ -894,6 +911,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_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/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());
diff --git a/src/mp_ui/Settings.cpp b/src/mp_ui/Settings.cpp
index b14f6f7..1140ff4 100644
--- a/src/mp_ui/Settings.cpp
+++ b/src/mp_ui/Settings.cpp
@@ -149,6 +149,66 @@ 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();
+ };
+
+ // 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] {
@@ -364,6 +424,28 @@ 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());
+}
+
+// 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_,
@@ -392,6 +474,22 @@ 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);
+ 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));
@@ -1779,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 5c04a61..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);
@@ -74,6 +95,18 @@ 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 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"};
@@ -345,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_;
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;