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;