diff --git a/.github/workflows/cmake_full_test.yml b/.github/workflows/cmake_full_test.yml index 51a9b8e6..fe6f4e21 100644 --- a/.github/workflows/cmake_full_test.yml +++ b/.github/workflows/cmake_full_test.yml @@ -136,7 +136,7 @@ jobs: run: | curl -LO "https://github.com/ZL-Audio/pluginval/releases/download/v1.0.4-temp/pluginval_${{ runner.os }}.zip" 7z x pluginval_${{ runner.os }}.zip - echo "PLUGINVAL_DISABLED=Plugin state restoration" >> $GITHUB_ENV + echo "PLUGINVAL_DISABLED=" >> $GITHUB_ENV # comma-separated tests to skip: none - name: Setup Pluginval random seed id: setup_seed diff --git a/.github/workflows/cmake_heavy_test.yml b/.github/workflows/cmake_heavy_test.yml index 42ad08ab..07e8a6f2 100644 --- a/.github/workflows/cmake_heavy_test.yml +++ b/.github/workflows/cmake_heavy_test.yml @@ -107,7 +107,7 @@ jobs: run: | curl -LO "https://github.com/ZL-Audio/pluginval/releases/download/v1.0.4-temp/pluginval_${{ runner.os }}.zip" 7z x pluginval_${{ runner.os }}.zip - echo "PLUGINVAL_DISABLED=Plugin state restoration" >> $GITHUB_ENV + echo "PLUGINVAL_DISABLED=" >> $GITHUB_ENV # comma-separated tests to skip: none - name: Setup Pluginval random seed id: setup_seed diff --git a/.github/workflows/sanitizer.yml b/.github/workflows/sanitizer.yml index cd7502e8..4cb14dc8 100644 --- a/.github/workflows/sanitizer.yml +++ b/.github/workflows/sanitizer.yml @@ -40,22 +40,22 @@ jobs: os: macos-latest pluginval-binary: ./pluginval/Builds/pluginval_artefacts/RelWithDebugInfo/pluginval.app/Contents/MacOS/pluginval cmake_extra_flags: -DWITH_ADDRESS_SANITIZER=ON -DWITH_THREAD_SANITIZER=OFF -DALTVANE_FORMATS="VST3;AU" -DALTVANE_SIMD_TARGET="NEON" - pluginval_disabled: Plugin state restoration + pluginval_disabled: "" - name: macOS Thread Sanitizer os: macos-latest pluginval-binary: ./pluginval/Builds/pluginval_artefacts/RelWithDebugInfo/pluginval.app/Contents/MacOS/pluginval cmake_extra_flags: -DWITH_ADDRESS_SANITIZER=OFF -DWITH_THREAD_SANITIZER=ON -DALTVANE_FORMATS="VST3;AU" -DALTVANE_SIMD_TARGET="NEON" - pluginval_disabled: Plugin state restoration + pluginval_disabled: "" - name: Linux Address Sanitizer os: ubuntu-latest pluginval-binary: ./pluginval/Builds/pluginval_artefacts/RelWithDebugInfo/pluginval cmake_extra_flags: -DCMAKE_C_COMPILER=clang-21 -DCMAKE_CXX_COMPILER=clang++-21 -DWITH_ADDRESS_SANITIZER=ON -DWITH_THREAD_SANITIZER=OFF -DALTVANE_FORMATS="VST3" -DALTVANE_SIMD_TARGET="AVX2" - pluginval_disabled: Plugin state restoration + pluginval_disabled: "" - name: Linux Thread Sanitizer os: ubuntu-latest pluginval-binary: ./pluginval/Builds/pluginval_artefacts/RelWithDebugInfo/pluginval cmake_extra_flags: -DCMAKE_C_COMPILER=clang-21 -DCMAKE_CXX_COMPILER=clang++-21 -DWITH_ADDRESS_SANITIZER=OFF -DWITH_THREAD_SANITIZER=ON -DALTVANE_FORMATS="VST3" -DALTVANE_SIMD_TARGET="AVX2" - pluginval_disabled: Plugin state restoration,Background thread state + pluginval_disabled: Background thread state steps: - name: Set up Clang (Linux) diff --git a/libs/ui/source/altvane_ui/session/state_schema.cpp b/libs/ui/source/altvane_ui/session/state_schema.cpp new file mode 100644 index 00000000..60951132 --- /dev/null +++ b/libs/ui/source/altvane_ui/session/state_schema.cpp @@ -0,0 +1,58 @@ +// Copyright (C) 2026 - Altvane +// This file is part of Altvane +// +// Altvane is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License Version 3 as published by the Free Software Foundation. +// +// Altvane is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License along with Altvane. If not, see . +#include "state_schema.hpp" + +namespace altvane::ui::state_schema { + int read(const juce::XmlElement& state) { + return std::max(state.getIntAttribute(kAttribute, 0), 0); + } + + void write(juce::XmlElement& state, const int version) { + state.setAttribute(kAttribute, version); + } + + int upgrade(juce::XmlElement& state, const int current, const std::span migrations) { + const auto saved = read(state); + if (saved > current) { + return saved; + } + // version 0 is version 1's layout, so the migrations start from 1 + auto version = std::max(saved, 1); + for (const auto& migration : migrations) { + jassert(migration.from >= 1 && migration.from < current); + if (migration.from == version) { + migration.apply(state); + ++version; + } + } + // anything short of current is a missing migration + jassert(version == current); + return saved; + } + + juce::ValueTree knownParameters(const juce::ValueTree& saved, const juce::AudioProcessorValueTreeState& parameters) { + juce::ValueTree result(saved.getType()); + for (const auto& child : saved) { + if (child.hasType("PARAM") && parameters.getParameter(child.getProperty("id").toString()) != nullptr) { + result.appendChild(child.createCopy(), nullptr); + } + } + return result; + } + + void settleSwitches(const juce::AudioProcessor& owner) { + for (auto* para : owner.getParameters()) { + if (const auto* toggle = dynamic_cast(para)) { + if (const auto settled = toggle->get() ? 1.f : 0.f; !juce::exactlyEqual(para->getValue(), settled)) { + para->setValueNotifyingHost(settled); + } + } + } + } +} diff --git a/libs/ui/source/altvane_ui/session/state_schema.hpp b/libs/ui/source/altvane_ui/session/state_schema.hpp new file mode 100644 index 00000000..96f64bd1 --- /dev/null +++ b/libs/ui/source/altvane_ui/session/state_schema.hpp @@ -0,0 +1,65 @@ +// Copyright (C) 2026 - Altvane +// This file is part of Altvane +// +// Altvane is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License Version 3 as published by the Free Software Foundation. +// +// Altvane is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License along with Altvane. If not, see . +#pragma once + +#include +#include + +/** + * The schema version of a product's saved state: host sessions and preset files alike, since presets are processor + * states too. Each product writes an integer on the root element of the state it saves, its own kStateVersion, and + * reads it back when it loads one: + * - no version (a state saved before versions existed) is version 0, whose layout is version 1's: it loads unchanged + * - an older version is brought up to date by the product's migrations, one version at a time, then loads + * - a newer version (saved by a later build) loads what this build knows of it: each parameter it has, by ID (IDs + * never change meaning, see each product's "parameter IDs never change" test), and whatever else the product reads + * by name (the Leveler's session part). Everything else is dropped, so the state saves again as this build's + * version, which a later build migrates like any other older state, without anything newer in it labelled older. + * Parameters it lacks keep their current values, as they do for an older state + * A product raises kStateVersion only when a layout change needs older states rewritten, and adds the migration from + * the previous version with it. + */ +namespace altvane::ui::state_schema { + inline constexpr auto kAttribute = "schema_version"; + + /** + * rewrites a state saved at version `from` (1 or later) into version from + 1's layout, in place + */ + struct Migration { + int from; + void (*apply)(juce::XmlElement& state); + }; + + /** + * the version a state was saved at: 0 if it has none + */ + int read(const juce::XmlElement& state); + + void write(juce::XmlElement& state, int version); + + /** + * brings a state up to `current` with the migrations from its version on (in order of `from`), and returns the + * version it was saved at; a newer state is left as it is, for the product to keep what it knows of it + */ + int upgrade(juce::XmlElement& state, int current, std::span migrations); + + /** + * a parameter tree from a newer state, cut down to the parameters `parameters` has: the same type, with only their + * PARAM children + */ + juce::ValueTree knownParameters(const juce::ValueTree& saved, const juce::AudioProcessorValueTreeState& parameters); + + /** + * after replaceState(): each switch exactly on the value the state gave it. A switch keeps whatever a host wrote to + * it (0.4 is off), unlike every other parameter type, which keeps its plain value; and replaceState() leaves a + * parameter alone when its plain value is already the saved one. So a switch the host had set to 0.4 would still + * report 0.4 once a state saying "off" was restored, rather than the 0 it was saved at. + */ + void settleSwitches(const juce::AudioProcessor& owner); +} diff --git a/products/compressor/CHANGELOG.md b/products/compressor/CHANGELOG.md index 3e569a4a..b6ad0581 100644 --- a/products/compressor/CHANGELOG.md +++ b/products/compressor/CHANGELOG.md @@ -32,6 +32,11 @@ First version of Altvane Compressor, based on the ZL Compressor engine (forked a ### Compatibility - Same parameters and state format as ZL Compressor: presets and sessions load unchanged. +- Sessions and presets record the layout of the state they were saved in (`schema_version="1"` on the state's root), + so a later version can upgrade them. A state without it, saved before, loads as it always has; one saved by a + newer version loads every setting this version has and leaves the rest. +- Restoring a session or preset puts each switch exactly where it was saved. One a host had left between on and off + (0.4 is off) went on reporting that value to the host. - Installs alongside ZL Compressor (different plug-in codes and bundle identifier). The upstream changelog is kept in [docs/UPSTREAM_CHANGELOG.md](docs/UPSTREAM_CHANGELOG.md). diff --git a/products/compressor/source/PluginProcessor.cpp b/products/compressor/source/PluginProcessor.cpp index 93c5ec82..46aa5246 100644 --- a/products/compressor/source/PluginProcessor.cpp +++ b/products/compressor/source/PluginProcessor.cpp @@ -8,9 +8,16 @@ // You should have received a copy of the GNU Affero General Public License along with ZLCompressor. If not, see . #include "PluginProcessor.hpp" + +#include + #include "ui/editor.hpp" namespace { + // Altvane: upgrades for states older than kStateVersion, in order (version 0, which ZL's releases and Altvane's + // before versions wrote, is version 1) + constexpr std::array kStateMigrations{}; + juce::ValueTree copyWithType(const juce::ValueTree& source, const juce::Identifier& type) { if (!source.isValid()) { return {}; @@ -587,6 +594,7 @@ void PluginProcessor::getStateInformation(juce::MemoryBlock& dest_data) { temp_tree.appendChild(parameters_.copyState(), nullptr); temp_tree.appendChild(na_parameters_.copyState(), nullptr); const std::unique_ptr xml(temp_tree.createXml()); + altvane::ui::state_schema::write(*xml, kStateVersion); copyXmlToBinary(*xml, dest_data); } @@ -598,6 +606,7 @@ void PluginProcessor::setStateInformation(const void* data, int size_in_bytes) { return; } + const auto version = altvane::ui::state_schema::upgrade(*xml_state, kStateVersion, kStateMigrations); const auto temp_tree = juce::ValueTree::fromXml(*xml_state); const auto parameter_state = getChildWithLegacyFallback( temp_tree, @@ -611,10 +620,15 @@ void PluginProcessor::setStateInformation(const void* data, int size_in_bytes) { return; } - parameters_.replaceState(copyWithType(parameter_state, + // Altvane: from a newer build, only the parameters this one has + const auto known = [version](const juce::ValueTree& saved, const juce::AudioProcessorValueTreeState& tree) { + return version > kStateVersion ? altvane::ui::state_schema::knownParameters(saved, tree) : saved; + }; + parameters_.replaceState(copyWithType(known(parameter_state, parameters_), juce::Identifier(zlstate::schema::kParameterState))); - na_parameters_.replaceState(copyWithType(non_automatable_state, + na_parameters_.replaceState(copyWithType(known(non_automatable_state, na_parameters_), juce::Identifier(zlstate::schema::kNonAutomatableState))); + altvane::ui::state_schema::settleSwitches(*this); } juce::AudioProcessor* JUCE_CALLTYPE diff --git a/products/compressor/source/PluginProcessor.hpp b/products/compressor/source/PluginProcessor.hpp index 77eeeb0f..9a416fd3 100644 --- a/products/compressor/source/PluginProcessor.hpp +++ b/products/compressor/source/PluginProcessor.hpp @@ -21,6 +21,12 @@ class PluginProcessor : public juce::AudioProcessor, private juce::AudioProcessorListener { public: + /** + * the layout of the saved state, sessions and presets alike: raise it only with a migration in + * PluginProcessor.cpp (see altvane_ui/session/state_schema.hpp) + */ + static constexpr int kStateVersion = 1; + zlstate::DummyProcessor dummy_processor_; juce::AudioProcessorValueTreeState parameters_; juce::AudioProcessorValueTreeState na_parameters_; diff --git a/products/compressor/source/ui/factory_presets.cpp b/products/compressor/source/ui/factory_presets.cpp index fb14b777..8d87abc2 100644 --- a/products/compressor/source/ui/factory_presets.cpp +++ b/products/compressor/source/ui/factory_presets.cpp @@ -8,6 +8,8 @@ // You should have received a copy of the GNU Affero General Public License along with Altvane Compressor. If not, see . #include "factory_presets.hpp" +#include + #include "../PluginProcessor.hpp" #include "../state/preset_json.hpp" @@ -150,6 +152,8 @@ namespace altvane::compressor { juce::ValueTree state(zlstate::schema::kProcessorState); state.appendChild(parameters, nullptr); state.appendChild(defaults(p.na_parameters_), nullptr); + // written in this build's layout, so it says so + state.setProperty(altvane::ui::state_schema::kAttribute, PluginProcessor::kStateVersion, nullptr); result.emplace_back(juce::String("Factory/") + preset.name, zlstate::PresetJson::toText(state)); } return result; diff --git a/products/compressor/tests/state_tests.cpp b/products/compressor/tests/state_tests.cpp new file mode 100644 index 00000000..94c64e01 --- /dev/null +++ b/products/compressor/tests/state_tests.cpp @@ -0,0 +1,182 @@ +// Copyright (C) 2026 - Altvane, based on ZLCompressor by zsliu98 +// This file is part of Altvane Compressor +// +// Altvane Compressor is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License Version 3 as published by the Free Software Foundation. +// +// Altvane Compressor is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License along with Altvane Compressor. If not, see . +#include +#include + +#include + +#include "test_helpers.hpp" +#include "state/preset_json.hpp" +#include "ui/factory_presets.hpp" + +using Catch::Matchers::WithinAbs; +using namespace test; +namespace schema = altvane::ui::state_schema; + +/* + * The schema version on Altvane's state (see altvane_ui/session/state_schema.hpp). ZL Compressor's own states, which + * have none, are compat_tests.cpp's fixtures. + */ +namespace { + juce::MemoryBlock stateOf(juce::AudioProcessor& p) { + juce::MemoryBlock block; + p.getStateInformation(block); + return block; + } + + std::unique_ptr xmlOf(const juce::MemoryBlock& block) { + auto xml = juce::AudioProcessor::getXmlFromBinary(block.getData(), static_cast(block.getSize())); + REQUIRE(xml != nullptr); + return xml; + } + + void load(juce::AudioProcessor& p, const juce::XmlElement& xml) { + juce::MemoryBlock block; + juce::AudioProcessor::copyXmlToBinary(xml, block); + p.setStateInformation(block.getData(), static_cast(block.getSize())); + } + + void edit(PluginProcessor& p) { + set(p, zlp::PCompStyle::kID, 2.f); + set(p, zlp::PThreshold::kID, -22.5f); + set(p, zlp::PRatio::kID, 3.f); + set(p, zlp::PFreq::kID + juce::String(2), 450.f); + set(p, zlp::PFilterStatus::kID + juce::String(2), 2.f); + set(p.na_parameters_, zlstate::PAnalyzerTimeLength::kID, 2.f); + } + + void checkSameParameters(PluginProcessor& a, PluginProcessor& b) { + for (const auto& [from, to] : { + std::pair{&a.parameters_, &b.parameters_}, std::pair{&a.na_parameters_, &b.na_parameters_} + }) { + const auto& owner = from == &a.parameters_ ? static_cast(a) : a.dummy_processor_; + for (auto* para : owner.getParameters()) { + const auto id = dynamic_cast(para)->getParameterID(); + CAPTURE(id); + CHECK_THAT(to->getParameter(id)->getValue(), WithinAbs(para->getValue(), 1e-6)); + } + } + } + + /** + * what pluginval's "Plugin state restoration" does at strictness 10: each automatable parameter but bypass moved, + * the saved state set back (which must restore it), and at the end the state read again must be the one that was + * set, byte for byte + */ + void checkRestoresExactly(juce::AudioProcessor& p, const int seed) { + const auto original = stateOf(p); + juce::Random random(seed); + for (auto* para : p.getParameters()) { + if (!para->isAutomatable() || para == p.getBypassParameter()) { + continue; + } + CAPTURE(para->getName(64)); + const auto before = para->getValue(); + para->setValueNotifyingHost(random.nextFloat()); + p.setStateInformation(original.getData(), static_cast(original.getSize())); + CHECK_THAT(para->getValue(), WithinAbs(before, 1e-6)); + } + const auto again = stateOf(p); + CHECK(xmlOf(again)->toString() == xmlOf(original)->toString()); + CHECK(again == original); + } +} + +TEST_CASE("the state carries its schema version, and round trips", "[state]") { + PluginProcessor a; + edit(a); + const auto state = stateOf(a); + const auto xml = xmlOf(state); + // on ZL Compressor's root; the attribute's name and its first value are part of the format + CHECK(xml->hasTagName(zlstate::schema::kProcessorState)); + CHECK(xml->getIntAttribute("schema_version") == 1); + CHECK(PluginProcessor::kStateVersion == 1); + + PluginProcessor b; + b.setStateInformation(state.getData(), static_cast(state.getSize())); + checkSameParameters(a, b); + CHECK(stateOf(b) == state); +} + +TEST_CASE("a state without a schema version, as 0.1.0 saved it, loads as it always has", "[state]") { + // 0.1.0 saved this build's layout without the version (older ZL Compressor states: compat_tests.cpp) + PluginProcessor a; + edit(a); + auto xml = xmlOf(stateOf(a)); + xml->removeAttribute(schema::kAttribute); + REQUIRE(schema::read(*xml) == 0); + PluginProcessor b; + load(b, *xml); + checkSameParameters(a, b); + CHECK(schema::read(*xmlOf(stateOf(b))) == PluginProcessor::kStateVersion); +} + +TEST_CASE("a state from a newer build loads the parameters this build knows, and saves as this build's version", "[state]") { + PluginProcessor a; + edit(a); + auto xml = xmlOf(stateOf(a)); + schema::write(*xml, PluginProcessor::kStateVersion + 1); + // what a later build might add: parameters, a property and a part this build has never heard of + for (auto* tree : xml->getChildIterator()) { + auto* future_param = tree->createNewChildElement("PARAM"); + future_param->setAttribute("id", "future_param"); + future_param->setAttribute("value", 3.0); + tree->setAttribute("future_property", "x"); + } + xml->setAttribute("future_property", "x"); + xml->createNewChildElement("FutureSection")->setAttribute("future_value", 1); + + PluginProcessor b; + load(b, *xml); + checkSameParameters(a, b); + const auto saved = xmlOf(stateOf(b)); + CHECK(schema::read(*saved) == PluginProcessor::kStateVersion); + CHECK_FALSE(saved->toString().contains("future")); + CHECK(stateOf(b) == stateOf(a)); +} + +TEST_CASE("the state restores exactly after every parameter moves, as pluginval checks", "[state]") { + PluginProcessor fresh; + checkRestoresExactly(fresh, 1); + + PluginProcessor edited; + juce::Random random(2); + for (auto* para : edited.getParameters()) { + para->setValueNotifyingHost(random.nextFloat()); + } + checkRestoresExactly(edited, 3); +} + +TEST_CASE("factory and user presets carry the schema version and load", "[state]") { + PluginProcessor p; + const auto codec = altvane::compressor::presetCodec(p); + for (const auto& [name, text] : altvane::compressor::factoryPresets(p)) { + CAPTURE(name); + const auto file = juce::File::createTempFile(".json"); + REQUIRE(file.replaceWithText(text)); + juce::MemoryBlock block; + REQUIRE(codec.read(file, block).wasOk()); + file.deleteFile(); + CHECK(schema::read(*xmlOf(block)) == PluginProcessor::kStateVersion); + p.setStateInformation(block.getData(), static_cast(block.getSize())); + CHECK(get(p, zlp::PSideStereoLink::kID) == 100.f); + } + + PluginProcessor a; + edit(a); + const auto file = juce::File::createTempFile(".json"); + REQUIRE(codec.write(file, stateOf(a)).wasOk()); + juce::MemoryBlock block; + REQUIRE(zlstate::PresetJson::read(file, block).wasOk()); + file.deleteFile(); + CHECK(schema::read(*xmlOf(block)) == PluginProcessor::kStateVersion); + PluginProcessor b; + b.setStateInformation(block.getData(), static_cast(block.getSize())); + checkSameParameters(a, b); +} diff --git a/products/deesser/CHANGELOG.md b/products/deesser/CHANGELOG.md index 0389c08e..570740ce 100644 --- a/products/deesser/CHANGELOG.md +++ b/products/deesser/CHANGELOG.md @@ -7,6 +7,14 @@ - Program-dependent release: after an ess the reduction now releases in 40 ms after a short one, up to 120 ms after a long one (0.25 s or more), as intended. The length of the ess was forgotten as soon as it ended, so every release took 40 ms. +- Restoring a session or preset puts each switch exactly where it was saved. One a host had left between on and off + (0.4 is off) went on reporting that value to the host. + +### Changed + +- Sessions and presets record the layout of the state they were saved in (`schema_version="1"` on the state's root), + so a later version can upgrade them. A state without it, saved before, loads as it always has; one saved by a + newer version loads every setting this version has and leaves the rest. ## 0.1.0 diff --git a/products/deesser/source/PluginProcessor.cpp b/products/deesser/source/PluginProcessor.cpp index 8095fc4f..0f40b6f0 100644 --- a/products/deesser/source/PluginProcessor.cpp +++ b/products/deesser/source/PluginProcessor.cpp @@ -8,10 +8,15 @@ // You should have received a copy of the GNU Affero General Public License along with Altvane De-Esser. If not, see . #include "PluginProcessor.hpp" +#include + #include "ui/editor.hpp" namespace altvane::deesser { namespace { + // upgrades for states older than kStateVersion, in order (version 0, from before versions, is version 1) + constexpr std::array kStateMigrations{}; + int choice(const std::atomic* value) { return juce::roundToInt(value->load(std::memory_order::relaxed)); } @@ -162,16 +167,22 @@ namespace altvane::deesser { auto state = parameters_.copyState(); state.setProperty("version", VERSION, nullptr); if (const auto xml = state.createXml()) { + ui::state_schema::write(*xml, kStateVersion); copyXmlToBinary(*xml, dest_data); } } void PluginProcessor::setStateInformation(const void* data, const int size_in_bytes) { - if (const auto xml = getXmlFromBinary(data, size_in_bytes)) { - if (xml->hasTagName(parameters_.state.getType())) { - parameters_.replaceState(juce::ValueTree::fromXml(*xml)); - } + const auto xml = getXmlFromBinary(data, size_in_bytes); + if (xml == nullptr || !xml->hasTagName(parameters_.state.getType())) { + return; } + const auto version = ui::state_schema::upgrade(*xml, kStateVersion, kStateMigrations); + const auto state = juce::ValueTree::fromXml(*xml); + // from a newer build: only the parameters this one has + parameters_.replaceState(version > kStateVersion ? ui::state_schema::knownParameters(state, parameters_) + : state); + ui::state_schema::settleSwitches(*this); } juce::AudioProcessorEditor* PluginProcessor::createEditor() { diff --git a/products/deesser/source/PluginProcessor.hpp b/products/deesser/source/PluginProcessor.hpp index 5c9037e7..ceaac205 100644 --- a/products/deesser/source/PluginProcessor.hpp +++ b/products/deesser/source/PluginProcessor.hpp @@ -20,6 +20,12 @@ namespace altvane::deesser { private juce::AudioProcessorValueTreeState::Listener, private juce::Timer { public: + /** + * the layout of the saved state, sessions and presets alike: raise it only with a migration in + * PluginProcessor.cpp (see altvane_ui/session/state_schema.hpp) + */ + static constexpr int kStateVersion = 1; + // edit history, A/B slots and current preset of the editor, kept so they survive closing the editor // (declared first: the parameters record their changes in its undo manager) ui::Session session_; diff --git a/products/deesser/source/ui/factory_presets.cpp b/products/deesser/source/ui/factory_presets.cpp index 1c3bddb7..fdb7e14b 100644 --- a/products/deesser/source/ui/factory_presets.cpp +++ b/products/deesser/source/ui/factory_presets.cpp @@ -9,8 +9,9 @@ #include "factory_presets.hpp" #include +#include -#include "../params.hpp" +#include "../PluginProcessor.hpp" namespace altvane::deesser { namespace { @@ -47,6 +48,8 @@ namespace altvane::deesser { add(params::kSideChain, 0.f); add(params::kOutput, 0.f); add(params::kBypass, 0.f); + // written in this build's layout, so it says so + ui::state_schema::write(state, PluginProcessor::kStateVersion); return ui::PresetCodec::toJsonText(state); } } diff --git a/products/deesser/tests/state_tests.cpp b/products/deesser/tests/state_tests.cpp new file mode 100644 index 00000000..a71243bf --- /dev/null +++ b/products/deesser/tests/state_tests.cpp @@ -0,0 +1,219 @@ +// Copyright (C) 2026 - Altvane +// This file is part of Altvane De-Esser +// +// Altvane De-Esser is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License Version 3 as published by the Free Software Foundation. +// +// Altvane De-Esser is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License along with Altvane De-Esser. If not, see . +#include + +#include +#include + +#include "PluginProcessor.hpp" +#include "ui/factory_presets.hpp" + +using namespace altvane::deesser; +namespace schema = altvane::ui::state_schema; + +namespace { + juce::MemoryBlock stateOf(juce::AudioProcessor& p) { + juce::MemoryBlock block; + p.getStateInformation(block); + return block; + } + + std::unique_ptr xmlOf(const juce::MemoryBlock& block) { + auto xml = juce::AudioProcessor::getXmlFromBinary(block.getData(), static_cast(block.getSize())); + REQUIRE(xml != nullptr); + return xml; + } + + void load(juce::AudioProcessor& p, const juce::XmlElement& xml) { + juce::MemoryBlock block; + juce::AudioProcessor::copyXmlToBinary(xml, block); + p.setStateInformation(block.getData(), static_cast(block.getSize())); + } + + void setParam(PluginProcessor& p, const juce::String& id, const float plain) { + auto* para = p.parameters_.getParameter(id); + para->setValueNotifyingHost(para->convertTo0to1(plain)); + } + + float plain(PluginProcessor& p, const juce::String& id) { + return p.parameters_.getRawParameterValue(id)->load(); + } + + void edit(PluginProcessor& p) { + setParam(p, params::kThreshold, -31.f); + setParam(p, params::kRange, 9.5f); + setParam(p, params::kMode, 1.f); + setParam(p, params::kShape, 1.f); + setParam(p, params::kBandLow, 4200.f); + setParam(p, params::kBandHigh, 9000.f); + setParam(p, params::kStereoMode, 2.f); + } + + void checkSameParameters(PluginProcessor& a, PluginProcessor& b) { + for (const auto& id : params::allIDs()) { + CAPTURE(id); + CHECK(std::abs(a.parameters_.getParameter(id)->getValue() - b.parameters_.getParameter(id)->getValue()) < 1e-6f); + } + } + + /** + * what pluginval's "Plugin state restoration" does at strictness 10: each automatable parameter but bypass moved, + * the saved state set back (which must restore it), and at the end the state read again must be the one that was + * set, byte for byte + */ + void checkRestoresExactly(juce::AudioProcessor& p, const int seed) { + const auto original = stateOf(p); + juce::Random random(seed); + for (auto* para : p.getParameters()) { + if (!para->isAutomatable() || para == p.getBypassParameter()) { + continue; + } + CAPTURE(para->getName(64)); + const auto before = para->getValue(); + para->setValueNotifyingHost(random.nextFloat()); + p.setStateInformation(original.getData(), static_cast(original.getSize())); + CHECK(std::abs(para->getValue() - before) < 1e-6f); + } + const auto again = stateOf(p); + CHECK(xmlOf(again)->toString() == xmlOf(original)->toString()); + CHECK(again == original); + } +} + +TEST_CASE("the state carries its schema version, and round trips", "[state]") { + PluginProcessor a; + edit(a); + const auto state = stateOf(a); + const auto xml = xmlOf(state); + // the attribute's name and its first value are part of the format + CHECK(xml->getIntAttribute("schema_version") == 1); + CHECK(PluginProcessor::kStateVersion == 1); + + PluginProcessor b; + b.setStateInformation(state.getData(), static_cast(state.getSize())); + checkSameParameters(a, b); + CHECK(stateOf(b) == state); +} + +TEST_CASE("a state without a schema version, as 0.1.0 saved it, loads as it always has", "[state]") { + // 0.1.0's layout: the parameter tree with the product's version, and no schema version + const auto legacy = juce::parseXML(R"( + + + + + + +)"); + REQUIRE(legacy != nullptr); + REQUIRE(schema::read(*legacy) == 0); + PluginProcessor p; + load(p, *legacy); + CHECK(std::abs(plain(p, params::kThreshold) + 28.5f) < 1e-4f); + CHECK(std::abs(plain(p, params::kRange) - 7.f) < 1e-4f); + CHECK(plain(p, params::kMode) == 1.f); + CHECK(std::abs(plain(p, params::kBandLow) - 5000.f) < 1e-2f); + CHECK(std::abs(plain(p, params::kOutput) + 1.5f) < 1e-4f); + + // the same as loading what this build saves, less the version + PluginProcessor a; + edit(a); + auto xml = xmlOf(stateOf(a)); + xml->removeAttribute(schema::kAttribute); + PluginProcessor b; + load(b, *xml); + checkSameParameters(a, b); + // and it saves again as the current version + CHECK(schema::read(*xmlOf(stateOf(b))) == PluginProcessor::kStateVersion); +} + +TEST_CASE("a state from a newer build loads the parameters this build knows, and saves as this build's version", "[state]") { + PluginProcessor a; + edit(a); + auto xml = xmlOf(stateOf(a)); + schema::write(*xml, PluginProcessor::kStateVersion + 1); + // what a later build might add: a parameter, a property and a part this build has never heard of + auto* future_param = xml->createNewChildElement("PARAM"); + future_param->setAttribute("id", "future_param"); + future_param->setAttribute("value", 3.0); + xml->setAttribute("future_property", "x"); + xml->createNewChildElement("FutureSection")->setAttribute("future_value", 1); + + PluginProcessor b; + load(b, *xml); + checkSameParameters(a, b); + const auto saved = xmlOf(stateOf(b)); + CHECK(schema::read(*saved) == PluginProcessor::kStateVersion); + CHECK_FALSE(saved->toString().contains("future")); + // and nothing else changes + CHECK(stateOf(b) == stateOf(a)); +} + +TEST_CASE("the state restores exactly after every parameter moves, as pluginval checks", "[state]") { + PluginProcessor fresh; + checkRestoresExactly(fresh, 1); + + PluginProcessor edited; + juce::Random random(2); + for (auto* para : edited.getParameters()) { + para->setValueNotifyingHost(random.nextFloat()); + } + checkRestoresExactly(edited, 3); +} + +TEST_CASE("factory and user presets carry the schema version and load", "[state]") { + const auto codec = altvane::ui::PresetCodec::xmlAsJson(); + for (const auto& [name, text] : factoryPresets()) { + CAPTURE(name); + const auto file = juce::File::createTempFile(".json"); + REQUIRE(file.replaceWithText(text)); + juce::MemoryBlock block; + REQUIRE(codec.read(file, block).wasOk()); + file.deleteFile(); + CHECK(schema::read(*xmlOf(block)) == PluginProcessor::kStateVersion); + PluginProcessor p; + p.setStateInformation(block.getData(), static_cast(block.getSize())); + CHECK(plain(p, params::kThreshold) < 0.f); + } + + PluginProcessor a; + edit(a); + const auto file = juce::File::createTempFile(".json"); + REQUIRE(codec.write(file, stateOf(a)).wasOk()); + juce::MemoryBlock block; + REQUIRE(codec.read(file, block).wasOk()); + file.deleteFile(); + CHECK(schema::read(*xmlOf(block)) == PluginProcessor::kStateVersion); + PluginProcessor b; + b.setStateInformation(block.getData(), static_cast(block.getSize())); + checkSameParameters(a, b); +} + +TEST_CASE("migrations bring an older state up one version at a time", "[state]") { + // the hook every product's setStateInformation runs: here with two made-up steps, to version 3 + static constexpr std::array migrations{{ + {1, [](juce::XmlElement& s) { s.setAttribute("steps", s.getStringAttribute("steps") + "1"); }}, + {2, [](juce::XmlElement& s) { s.setAttribute("steps", s.getStringAttribute("steps") + "2"); }}, + }}; + const auto run = [&](const int saved) { + juce::XmlElement state("State"); + if (saved > 0) { + schema::write(state, saved); + } + CHECK(schema::upgrade(state, 3, migrations) == saved); + return state.getStringAttribute("steps"); + }; + // no version is version 1's layout + CHECK(run(0) == "12"); + CHECK(run(1) == "12"); + CHECK(run(2) == "2"); + CHECK(run(3) == ""); + // a newer state is left for the product to take what it knows of + CHECK(run(4) == ""); +} diff --git a/products/dialogue/CHANGELOG.md b/products/dialogue/CHANGELOG.md index 46eedf95..33f1b691 100644 --- a/products/dialogue/CHANGELOG.md +++ b/products/dialogue/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +### Changed + +- Sessions and presets record the layout of the state they were saved in (`schema_version="1"` on the state's root), + so a later version can upgrade them. A state without it, saved before, loads as it always has; one saved by a + newer version loads every setting this version has and leaves the rest. + +### Fixed + +- Restoring a session or preset puts each switch exactly where it was saved. One a host had left between on and off + (0.4 is off) went on reporting that value to the host. + ## 0.1.0 First release of Altvane Dialogue, for macOS 11 or newer (Apple silicon and Intel) and Windows x64. diff --git a/products/dialogue/source/PluginProcessor.cpp b/products/dialogue/source/PluginProcessor.cpp index e700d642..3d7833a1 100644 --- a/products/dialogue/source/PluginProcessor.cpp +++ b/products/dialogue/source/PluginProcessor.cpp @@ -8,9 +8,16 @@ // You should have received a copy of the GNU Affero General Public License along with Altvane Dialogue. If not, see . #include "PluginProcessor.hpp" +#include + #include "ui/editor.hpp" namespace altvane::dialogue { + namespace { + // upgrades for states older than kStateVersion, in order (version 0, from before versions, is version 1) + constexpr std::array kStateMigrations{}; + } + PluginProcessor::PluginProcessor() : AudioProcessor(BusesProperties() .withInput("Input", juce::AudioChannelSet::stereo(), true) @@ -217,16 +224,22 @@ namespace altvane::dialogue { auto state = parameters_.copyState(); state.setProperty("version", VERSION, nullptr); if (const auto xml = state.createXml()) { + ui::state_schema::write(*xml, kStateVersion); copyXmlToBinary(*xml, dest_data); } } void PluginProcessor::setStateInformation(const void* data, const int size_in_bytes) { - if (const auto xml = getXmlFromBinary(data, size_in_bytes)) { - if (xml->hasTagName(parameters_.state.getType())) { - parameters_.replaceState(juce::ValueTree::fromXml(*xml)); - } + const auto xml = getXmlFromBinary(data, size_in_bytes); + if (xml == nullptr || !xml->hasTagName(parameters_.state.getType())) { + return; } + const auto version = ui::state_schema::upgrade(*xml, kStateVersion, kStateMigrations); + const auto state = juce::ValueTree::fromXml(*xml); + // from a newer build: only the parameters this one has + parameters_.replaceState(version > kStateVersion ? ui::state_schema::knownParameters(state, parameters_) + : state); + ui::state_schema::settleSwitches(*this); } juce::AudioProcessorEditor* PluginProcessor::createEditor() { diff --git a/products/dialogue/source/PluginProcessor.hpp b/products/dialogue/source/PluginProcessor.hpp index b7d9384d..09c7150c 100644 --- a/products/dialogue/source/PluginProcessor.hpp +++ b/products/dialogue/source/PluginProcessor.hpp @@ -18,6 +18,12 @@ namespace altvane::dialogue { class PluginProcessor final : public juce::AudioProcessor { public: + /** + * the layout of the saved state, sessions and presets alike: raise it only with a migration in + * PluginProcessor.cpp (see altvane_ui/session/state_schema.hpp) + */ + static constexpr int kStateVersion = 1; + /** * output buses 1-3 carry the voice, ambience and reverb, each at its level and the output gain, so together * they add up to the main output; off until the host enables them diff --git a/products/dialogue/source/ui/factory_presets.cpp b/products/dialogue/source/ui/factory_presets.cpp index 8d75a392..b7f01a3a 100644 --- a/products/dialogue/source/ui/factory_presets.cpp +++ b/products/dialogue/source/ui/factory_presets.cpp @@ -9,8 +9,9 @@ #include "factory_presets.hpp" #include +#include -#include "../params.hpp" +#include "../PluginProcessor.hpp" namespace altvane::dialogue { namespace { @@ -46,6 +47,8 @@ namespace altvane::dialogue { add(params::sensitivityID(s, n), value); } } + // written in this build's layout, so it says so + ui::state_schema::write(state, PluginProcessor::kStateVersion); return ui::PresetCodec::toJsonText(state); } } diff --git a/products/dialogue/tests/state_tests.cpp b/products/dialogue/tests/state_tests.cpp new file mode 100644 index 00000000..101c2b8a --- /dev/null +++ b/products/dialogue/tests/state_tests.cpp @@ -0,0 +1,190 @@ +// Copyright (C) 2026 - Altvane +// This file is part of Altvane Dialogue +// +// Altvane Dialogue is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License Version 3 as published by the Free Software Foundation. +// +// Altvane Dialogue is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License along with Altvane Dialogue. If not, see . +#include + +#include +#include + +#include "PluginProcessor.hpp" +#include "ui/factory_presets.hpp" + +using namespace altvane::dialogue; +namespace schema = altvane::ui::state_schema; + +namespace { + juce::MemoryBlock stateOf(juce::AudioProcessor& p) { + juce::MemoryBlock block; + p.getStateInformation(block); + return block; + } + + std::unique_ptr xmlOf(const juce::MemoryBlock& block) { + auto xml = juce::AudioProcessor::getXmlFromBinary(block.getData(), static_cast(block.getSize())); + REQUIRE(xml != nullptr); + return xml; + } + + void load(juce::AudioProcessor& p, const juce::XmlElement& xml) { + juce::MemoryBlock block; + juce::AudioProcessor::copyXmlToBinary(xml, block); + p.setStateInformation(block.getData(), static_cast(block.getSize())); + } + + void setParam(PluginProcessor& p, const juce::String& id, const float plain) { + auto* para = p.parameters_.getParameter(id); + para->setValueNotifyingHost(para->convertTo0to1(plain)); + } + + float plain(PluginProcessor& p, const juce::String& id) { + return p.parameters_.getRawParameterValue(id)->load(); + } + + void edit(PluginProcessor& p) { + setParam(p, params::kVoice, -3.f); + setParam(p, params::kAmbience, -18.f); + setParam(p, params::kReverb, params::kLevelOff); + setParam(p, params::sensitivityID(dsp::kAmbience, 2), .5f); + setParam(p, params::kQuality, 0.f); + setParam(p, params::kStereo, 2.f); + } + + void checkSameParameters(PluginProcessor& a, PluginProcessor& b) { + for (const auto& id : params::allIDs()) { + CAPTURE(id); + CHECK(std::abs(a.parameters_.getParameter(id)->getValue() - b.parameters_.getParameter(id)->getValue()) < 1e-6f); + } + } + + /** + * what pluginval's "Plugin state restoration" does at strictness 10: each automatable parameter but bypass moved, + * the saved state set back (which must restore it), and at the end the state read again must be the one that was + * set, byte for byte + */ + void checkRestoresExactly(juce::AudioProcessor& p, const int seed) { + const auto original = stateOf(p); + juce::Random random(seed); + for (auto* para : p.getParameters()) { + if (!para->isAutomatable() || para == p.getBypassParameter()) { + continue; + } + CAPTURE(para->getName(64)); + const auto before = para->getValue(); + para->setValueNotifyingHost(random.nextFloat()); + p.setStateInformation(original.getData(), static_cast(original.getSize())); + CHECK(std::abs(para->getValue() - before) < 1e-6f); + } + const auto again = stateOf(p); + CHECK(xmlOf(again)->toString() == xmlOf(original)->toString()); + CHECK(again == original); + } +} + +TEST_CASE("the state carries its schema version, and round trips", "[state]") { + PluginProcessor a; + edit(a); + const auto state = stateOf(a); + // the attribute's name and its first value are part of the format + CHECK(xmlOf(state)->getIntAttribute("schema_version") == 1); + CHECK(PluginProcessor::kStateVersion == 1); + + PluginProcessor b; + b.setStateInformation(state.getData(), static_cast(state.getSize())); + checkSameParameters(a, b); + CHECK(stateOf(b) == state); +} + +TEST_CASE("a state without a schema version, as 0.1.0 saved it, loads as it always has", "[state]") { + // 0.1.0's layout: the parameter tree with the product's version, and no schema version + const auto legacy = juce::parseXML(R"( + + + + + +)"); + REQUIRE(legacy != nullptr); + REQUIRE(schema::read(*legacy) == 0); + PluginProcessor p; + load(p, *legacy); + CHECK(std::abs(plain(p, params::kVoice) - 1.5f) < 1e-4f); + CHECK(plain(p, params::kAmbience) == params::kLevelOff); + CHECK(std::abs(plain(p, params::sensitivityID(dsp::kAmbience, 2)) - .25f) < 1e-4f); + CHECK(plain(p, params::kQuality) == 0.f); + + // the same as loading what this build saves, less the version + PluginProcessor a; + edit(a); + auto xml = xmlOf(stateOf(a)); + xml->removeAttribute(schema::kAttribute); + PluginProcessor b; + load(b, *xml); + checkSameParameters(a, b); + CHECK(schema::read(*xmlOf(stateOf(b))) == PluginProcessor::kStateVersion); +} + +TEST_CASE("a state from a newer build loads the parameters this build knows, and saves as this build's version", "[state]") { + PluginProcessor a; + edit(a); + auto xml = xmlOf(stateOf(a)); + schema::write(*xml, PluginProcessor::kStateVersion + 1); + // what a later build might add: a parameter, a property and a part this build has never heard of + auto* future_param = xml->createNewChildElement("PARAM"); + future_param->setAttribute("id", "future_param"); + future_param->setAttribute("value", 3.0); + xml->setAttribute("future_property", "x"); + xml->createNewChildElement("FutureSection")->setAttribute("future_value", 1); + + PluginProcessor b; + load(b, *xml); + checkSameParameters(a, b); + const auto saved = xmlOf(stateOf(b)); + CHECK(schema::read(*saved) == PluginProcessor::kStateVersion); + CHECK_FALSE(saved->toString().contains("future")); + CHECK(stateOf(b) == stateOf(a)); +} + +TEST_CASE("the state restores exactly after every parameter moves, as pluginval checks", "[state]") { + PluginProcessor fresh; + checkRestoresExactly(fresh, 1); + + PluginProcessor edited; + juce::Random random(2); + for (auto* para : edited.getParameters()) { + para->setValueNotifyingHost(random.nextFloat()); + } + checkRestoresExactly(edited, 3); +} + +TEST_CASE("factory and user presets carry the schema version and load", "[state]") { + const auto codec = altvane::ui::PresetCodec::xmlAsJson(); + for (const auto& [name, text] : factoryPresets()) { + CAPTURE(name); + const auto file = juce::File::createTempFile(".json"); + REQUIRE(file.replaceWithText(text)); + juce::MemoryBlock block; + REQUIRE(codec.read(file, block).wasOk()); + file.deleteFile(); + CHECK(schema::read(*xmlOf(block)) == PluginProcessor::kStateVersion); + PluginProcessor p; + p.setStateInformation(block.getData(), static_cast(block.getSize())); + CHECK(plain(p, params::kQuality) == 1.f); + } + + PluginProcessor a; + edit(a); + const auto file = juce::File::createTempFile(".json"); + REQUIRE(codec.write(file, stateOf(a)).wasOk()); + juce::MemoryBlock block; + REQUIRE(codec.read(file, block).wasOk()); + file.deleteFile(); + CHECK(schema::read(*xmlOf(block)) == PluginProcessor::kStateVersion); + PluginProcessor b; + b.setStateInformation(block.getData(), static_cast(block.getSize())); + checkSameParameters(a, b); +} diff --git a/products/eq/CHANGELOG.md b/products/eq/CHANGELOG.md index 0ca803e0..8d68d8d2 100644 --- a/products/eq/CHANGELOG.md +++ b/products/eq/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +### Changed + +- Sessions and presets record the layout of the state they were saved in (`schema_version="1"` on the state's root), + so a later version can upgrade them. A state without it, saved before, loads as it always has; one saved by a + newer version loads every setting this version has and leaves the rest. + +### Fixed + +- Restoring a session or preset puts each switch exactly where it was saved. One a host had left between on and off + (0.4 is off) went on reporting that value to the host. + ## 0.1.0 First release of Altvane EQ (developed as "Contour"), based on the ZL Equalizer 2.x engine (forked at 1.4.1). diff --git a/products/eq/source/PluginProcessor.cpp b/products/eq/source/PluginProcessor.cpp index 9dec9871..0d0c529a 100644 --- a/products/eq/source/PluginProcessor.cpp +++ b/products/eq/source/PluginProcessor.cpp @@ -8,9 +8,16 @@ // You should have received a copy of the GNU Affero General Public License along with ZLEqualizer. If not, see . #include "PluginProcessor.hpp" + +#include + #include "ui/editor.hpp" namespace { + // Altvane: upgrades for states older than kStateVersion, in order (version 0, which ZL's releases and Altvane's + // before versions wrote, is version 1) + constexpr std::array kStateMigrations{}; + juce::ValueTree copyWithType(const juce::ValueTree& source, const juce::Identifier& type) { if (!source.isValid()) { return {}; @@ -369,6 +376,7 @@ void PluginProcessor::getStateInformation(juce::MemoryBlock& dest_data) { temp_tree.appendChild(parameters_.copyState(), nullptr); temp_tree.appendChild(parameters_NA_.copyState(), nullptr); const std::unique_ptr xml(temp_tree.createXml()); + altvane::ui::state_schema::write(*xml, kStateVersion); copyXmlToBinary(*xml, dest_data); } @@ -380,6 +388,7 @@ void PluginProcessor::setStateInformation(const void* data, const int size_in_by return; } + const auto version = altvane::ui::state_schema::upgrade(*xml_state, kStateVersion, kStateMigrations); const auto temp_tree = juce::ValueTree::fromXml(*xml_state); const auto parameter_state = getChildWithLegacyFallback( temp_tree, @@ -393,10 +402,15 @@ void PluginProcessor::setStateInformation(const void* data, const int size_in_by return; } - parameters_.replaceState(copyWithType(parameter_state, + // Altvane: from a newer build, only the parameters this one has + const auto known = [version](const juce::ValueTree& saved, const juce::AudioProcessorValueTreeState& tree) { + return version > kStateVersion ? altvane::ui::state_schema::knownParameters(saved, tree) : saved; + }; + parameters_.replaceState(copyWithType(known(parameter_state, parameters_), juce::Identifier(zlstate::schema::kParameterState))); - parameters_NA_.replaceState(copyWithType(non_automatable_state, + parameters_NA_.replaceState(copyWithType(known(non_automatable_state, parameters_NA_), juce::Identifier(zlstate::schema::kNonAutomatableState))); + altvane::ui::state_schema::settleSwitches(*this); } void PluginProcessor::updateChannelLayout() { diff --git a/products/eq/source/PluginProcessor.hpp b/products/eq/source/PluginProcessor.hpp index 1289cc70..a603f7cf 100644 --- a/products/eq/source/PluginProcessor.hpp +++ b/products/eq/source/PluginProcessor.hpp @@ -17,6 +17,12 @@ class PluginProcessor final : public juce::AudioProcessor { public: + /** + * the layout of the saved state, sessions and presets alike: raise it only with a migration in + * PluginProcessor.cpp (see altvane_ui/session/state_schema.hpp) + */ + static constexpr int kStateVersion = 1; + zlstate::DummyProcessor dummy_processor_; juce::AudioProcessorValueTreeState parameters_; juce::AudioProcessorValueTreeState parameters_NA_; diff --git a/products/eq/tests/state_tests.cpp b/products/eq/tests/state_tests.cpp index e926252e..007dbccf 100644 --- a/products/eq/tests/state_tests.cpp +++ b/products/eq/tests/state_tests.cpp @@ -11,6 +11,7 @@ #include "test_helpers.hpp" #include "state/preset_json.hpp" +#include "altvane_ui/session/state_schema.hpp" using Catch::Matchers::WithinAbs; using FT = zldsp::filter::FilterType; @@ -223,3 +224,151 @@ TEST_CASE("a ZL Equalizer 2 1.4.1 preset loads", "[state]") { CHECK_THAT(value(zlp::PPhaseFlip::kID), WithinAbs(1.f, 1e-6)); CHECK_THAT(p.parameters_NA_.getParameter(zlstate::PPianoRollON::kID)->getValue(), WithinAbs(0.0, 1e-6)); } + +/* + * The schema version on Altvane's state (see altvane_ui/session/state_schema.hpp). ZL Equalizer 2's own states, which + * have none, are the fixtures above. + */ +namespace { + juce::MemoryBlock stateOf(juce::AudioProcessor& p) { + juce::MemoryBlock block; + p.getStateInformation(block); + return block; + } + + std::unique_ptr xmlOf(const juce::MemoryBlock& block) { + auto xml = juce::AudioProcessor::getXmlFromBinary(block.getData(), static_cast(block.getSize())); + REQUIRE(xml != nullptr); + return xml; + } + + void load(juce::AudioProcessor& p, const juce::XmlElement& xml) { + juce::MemoryBlock block; + juce::AudioProcessor::copyXmlToBinary(xml, block); + p.setStateInformation(block.getData(), static_cast(block.getSize())); + } + + void edit(PluginProcessor& p) { + set(p, zlp::POutputGain::kID, -2.5f); + setBand(p, 3, zlp::PFilterStatus::kID, static_cast(zlp::kOn)); + setBand(p, 3, zlp::PFilterType::kID, static_cast(FT::kHighShelf)); + setBand(p, 3, zlp::PFreq::kID, 7200.f); + setBand(p, 3, zlp::PGain::kID, -3.75f); + set(p.parameters_NA_, zlstate::PEQMaxDB::kID, 2.f); + } + + void checkSameParameters(PluginProcessor& a, PluginProcessor& b) { + for (const auto* owner : {static_cast(&a), + static_cast(&a.dummy_processor_)}) { + auto& to = owner == &a ? b.parameters_ : b.parameters_NA_; + for (auto* para : parametersOf(*owner)) { + CAPTURE(para->getParameterID()); + CHECK_THAT(to.getParameter(para->getParameterID())->getValue(), WithinAbs(para->getValue(), 1e-6)); + } + } + } + + /** + * what pluginval's "Plugin state restoration" does at strictness 10: each automatable parameter but bypass moved, + * the saved state set back (which must restore it), and at the end the state read again must be the one that was + * set, byte for byte + */ + void checkRestoresExactly(juce::AudioProcessor& p, const int seed) { + const auto original = stateOf(p); + juce::Random random(seed); + for (auto* para : p.getParameters()) { + if (!para->isAutomatable() || para == p.getBypassParameter()) { + continue; + } + CAPTURE(para->getName(64)); + const auto before = para->getValue(); + para->setValueNotifyingHost(random.nextFloat()); + p.setStateInformation(original.getData(), static_cast(original.getSize())); + CHECK_THAT(para->getValue(), WithinAbs(before, 1e-6)); + } + const auto again = stateOf(p); + CHECK(xmlOf(again)->toString() == xmlOf(original)->toString()); + CHECK(again == original); + } +} + +TEST_CASE("the state carries its schema version, and round trips", "[state]") { + PluginProcessor a; + edit(a); + const auto state = stateOf(a); + // on ZL Equalizer 2's root; the attribute's name and its first value are part of the format + const auto xml = xmlOf(state); + CHECK(xml->hasTagName(zlstate::schema::kProcessorState)); + CHECK(xml->getIntAttribute("schema_version") == 1); + CHECK(PluginProcessor::kStateVersion == 1); + + PluginProcessor b; + b.setStateInformation(state.getData(), static_cast(state.getSize())); + checkSameParameters(a, b); + CHECK(stateOf(b) == state); +} + +TEST_CASE("a state without a schema version, as 0.1.0 saved it, loads as it always has", "[state]") { + // 0.1.0 saved this build's layout without the version (ZL Equalizer 2's own: the fixtures) + PluginProcessor a; + edit(a); + auto xml = xmlOf(stateOf(a)); + xml->removeAttribute(altvane::ui::state_schema::kAttribute); + REQUIRE(altvane::ui::state_schema::read(*xml) == 0); + PluginProcessor b; + dirty(b); + dirty(b.dummy_processor_); + load(b, *xml); + checkSameParameters(a, b); + CHECK(altvane::ui::state_schema::read(*xmlOf(stateOf(b))) == PluginProcessor::kStateVersion); +} + +TEST_CASE("a state from a newer build loads the parameters this build knows, and saves as this build's version", "[state]") { + PluginProcessor a; + edit(a); + auto xml = xmlOf(stateOf(a)); + altvane::ui::state_schema::write(*xml, PluginProcessor::kStateVersion + 1); + // what a later build might add: parameters, a property and a part this build has never heard of + for (auto* tree : xml->getChildIterator()) { + auto* future_param = tree->createNewChildElement("PARAM"); + future_param->setAttribute("id", "future_param"); + future_param->setAttribute("value", 3.0); + tree->setAttribute("future_property", "x"); + } + xml->setAttribute("future_property", "x"); + xml->createNewChildElement("FutureSection")->setAttribute("future_value", 1); + + PluginProcessor b; + load(b, *xml); + checkSameParameters(a, b); + const auto saved = xmlOf(stateOf(b)); + CHECK(altvane::ui::state_schema::read(*saved) == PluginProcessor::kStateVersion); + CHECK_FALSE(saved->toString().contains("future")); + CHECK(stateOf(b) == stateOf(a)); +} + +TEST_CASE("the state restores exactly after every parameter moves, as pluginval checks", "[state]") { + PluginProcessor fresh; + checkRestoresExactly(fresh, 1); + + PluginProcessor edited; + juce::Random random(2); + for (auto* para : edited.getParameters()) { + para->setValueNotifyingHost(random.nextFloat()); + } + checkRestoresExactly(edited, 3); +} + +TEST_CASE("user presets carry the schema version and load", "[state]") { + PluginProcessor a; + edit(a); + const auto file = juce::File::createTempFile(".json"); + REQUIRE(zlstate::PresetJson::write(file, stateOf(a)).wasOk()); + juce::MemoryBlock block; + REQUIRE(zlstate::PresetJson::read(file, block).wasOk()); + file.deleteFile(); + CHECK(altvane::ui::state_schema::read(*xmlOf(block)) == PluginProcessor::kStateVersion); + PluginProcessor b; + b.setStateInformation(block.getData(), static_cast(block.getSize())); + checkSameParameters(a, b); +} diff --git a/products/leveler/CHANGELOG.md b/products/leveler/CHANGELOG.md index ae78f995..46a02236 100644 --- a/products/leveler/CHANGELOG.md +++ b/products/leveler/CHANGELOG.md @@ -24,6 +24,16 @@ First version of Altvane Leveler, an automatic vocal rider. - Loading a session restores the fader without the host taking it for an automation write. - Presets and A/B never move the fader, and loading a preset never changes the automation mode. +### Sessions and presets + +- Sessions and presets record the layout of the state they were saved in (`schema_version="1"` on the state's root), + so a later version can upgrade them. A state without it, saved before, loads as it always has; one saved by a + newer version loads every setting this version has and leaves the rest. +- Restoring a session or preset puts each switch exactly where it was saved. One a host had left between on and off + (0.4 is off) went on reporting that value to the host. +- The rider fader is saved at the value it reports, so a restored session saves back unchanged. It was saved at its + default, 0 dB, and restored a step's rounding error away from it. + ### Interface - An 8-second history of input, output and music levels, the target and floor lines (draggable), the gain and the diff --git a/products/leveler/source/PluginProcessor.cpp b/products/leveler/source/PluginProcessor.cpp index 49068908..cb1fba6d 100644 --- a/products/leveler/source/PluginProcessor.cpp +++ b/products/leveler/source/PluginProcessor.cpp @@ -8,6 +8,8 @@ // You should have received a copy of the GNU Affero General Public License along with Altvane Leveler. If not, see . #include "PluginProcessor.hpp" +#include + #include "ui/editor.hpp" namespace altvane::leveler { @@ -16,6 +18,9 @@ namespace altvane::leveler { const juce::Identifier kSessionTag{"Session"}; const juce::Identifier kModeProperty{"mode"}; const juce::Identifier kRideProperty{"ride"}; + + // upgrades for states older than kStateVersion, in order (version 0, from before versions, is version 1) + constexpr std::array kStateMigrations{}; } PluginProcessor::PluginProcessor() : @@ -236,9 +241,13 @@ namespace altvane::leveler { auto state = parameters_.copyState(); state.setProperty("version", VERSION, nullptr); if (const auto xml = state.createXml()) { + ui::state_schema::write(*xml, kStateVersion); auto* session = xml->createNewChildElement(kSessionTag.toString()); session->setAttribute(kModeProperty, static_cast(getMode())); - session->setAttribute(kRideProperty, static_cast(ride_->get())); + // as the parameter tree stores its parameters: the value its normalised position gives, which restores to + // itself (the default, 0, is not one: a set value is the range's start plus a whole number of steps) + const auto ride = ride_->convertFrom0to1(ride_->convertTo0to1(ride_->get())); + session->setAttribute(kRideProperty, static_cast(ride)); copyXmlToBinary(*xml, dest_data); } } @@ -248,6 +257,7 @@ namespace altvane::leveler { if (xml == nullptr || !xml->hasTagName(parameters_.state.getType())) { return; } + const auto version = ui::state_schema::upgrade(*xml, kStateVersion, kStateMigrations); // presets carry no session part: a preset never switches a track into Write or moves the fader if (const auto* session = xml->getChildByName(kSessionTag)) { engine_.getControls().mode.store(std::clamp(session->getIntAttribute(kModeProperty), 0, 2)); @@ -259,7 +269,11 @@ namespace altvane::leveler { engine_.getControls().ride.store(ride_->get(), std::memory_order::relaxed); xml->removeChildElement(xml->getChildByName(kSessionTag), true); } - parameters_.replaceState(juce::ValueTree::fromXml(*xml)); + const auto state = juce::ValueTree::fromXml(*xml); + // from a newer build: only the parameters this one has + parameters_.replaceState(version > kStateVersion ? ui::state_schema::knownParameters(state, parameters_) + : state); + ui::state_schema::settleSwitches(*this); } juce::AudioProcessorEditor* PluginProcessor::createEditor() { diff --git a/products/leveler/source/PluginProcessor.hpp b/products/leveler/source/PluginProcessor.hpp index 1f6a4364..4568138f 100644 --- a/products/leveler/source/PluginProcessor.hpp +++ b/products/leveler/source/PluginProcessor.hpp @@ -18,6 +18,12 @@ namespace altvane::leveler { class PluginProcessor final : public juce::AudioProcessor, private juce::Timer { public: + /** + * the layout of the saved state, sessions and presets alike: raise it only with a migration in + * PluginProcessor.cpp (see altvane_ui/session/state_schema.hpp) + */ + static constexpr int kStateVersion = 1; + // edit history, A/B slots and current preset of the editor, kept so they survive closing the editor ui::Session session_; juce::AudioProcessorValueTreeState parameters_; diff --git a/products/leveler/source/ui/factory_presets.cpp b/products/leveler/source/ui/factory_presets.cpp index c04c3880..b8b930b8 100644 --- a/products/leveler/source/ui/factory_presets.cpp +++ b/products/leveler/source/ui/factory_presets.cpp @@ -9,8 +9,9 @@ #include "factory_presets.hpp" #include +#include -#include "../params.hpp" +#include "../PluginProcessor.hpp" namespace altvane::leveler { namespace { @@ -38,6 +39,8 @@ namespace altvane::leveler { add(params::kLookahead, preset.lookahead ? 1.f : 0.f); add(params::kOutput, 0.f); add(params::kBypass, 0.f); + // written in this build's layout, so it says so + ui::state_schema::write(state, PluginProcessor::kStateVersion); return ui::PresetCodec::toJsonText(state); } } diff --git a/products/leveler/tests/state_tests.cpp b/products/leveler/tests/state_tests.cpp new file mode 100644 index 00000000..78ffcd6d --- /dev/null +++ b/products/leveler/tests/state_tests.cpp @@ -0,0 +1,200 @@ +// Copyright (C) 2026 - Altvane +// This file is part of Altvane Leveler +// +// Altvane Leveler is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License Version 3 as published by the Free Software Foundation. +// +// Altvane Leveler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License along with Altvane Leveler. If not, see . +#include + +#include + +#include "PluginProcessor.hpp" +#include "presets.hpp" +#include "ui/factory_presets.hpp" + +using namespace altvane::leveler; +namespace schema = altvane::ui::state_schema; + +namespace { + juce::MemoryBlock stateOf(juce::AudioProcessor& p) { + juce::MemoryBlock block; + p.getStateInformation(block); + return block; + } + + std::unique_ptr xmlOf(const juce::MemoryBlock& block) { + auto xml = juce::AudioProcessor::getXmlFromBinary(block.getData(), static_cast(block.getSize())); + REQUIRE(xml != nullptr); + return xml; + } + + void load(juce::AudioProcessor& p, const juce::XmlElement& xml) { + juce::MemoryBlock block; + juce::AudioProcessor::copyXmlToBinary(xml, block); + p.setStateInformation(block.getData(), static_cast(block.getSize())); + } + + void setParam(PluginProcessor& p, const juce::String& id, const float plain) { + auto* para = p.parameters_.getParameter(id); + para->setValueNotifyingHost(para->convertTo0to1(plain)); + } + + float plain(PluginProcessor& p, const juce::String& id) { + return p.parameters_.getRawParameterValue(id)->load(); + } + + void edit(PluginProcessor& p) { + setParam(p, params::kTarget, -14.5f); + setParam(p, params::kSpeed, .3f); + setParam(p, params::kGuard, .25f); + setParam(p, params::kRangeMin, -9.f); + setParam(p, params::kLookahead, 0.f); + p.setMode(dsp::Mode::kRead); + *p.getRide() = 3.25f; + } + + void checkSameState(PluginProcessor& a, PluginProcessor& b) { + for (const auto& id : params::allIDs()) { + CAPTURE(id); + CHECK(std::abs(a.parameters_.getParameter(id)->getValue() - b.parameters_.getParameter(id)->getValue()) < 1e-6f); + } + CHECK(a.getMode() == b.getMode()); + CHECK(a.getRide()->get() == b.getRide()->get()); + } + + /** + * what pluginval's "Plugin state restoration" does at strictness 10: each automatable parameter but bypass moved, + * the saved state set back (which must restore it), and at the end the state read again must be the one that was + * set, byte for byte + */ + void checkRestoresExactly(juce::AudioProcessor& p, const int seed) { + const auto original = stateOf(p); + juce::Random random(seed); + for (auto* para : p.getParameters()) { + if (!para->isAutomatable() || para == p.getBypassParameter()) { + continue; + } + CAPTURE(para->getName(64)); + const auto before = para->getValue(); + para->setValueNotifyingHost(random.nextFloat()); + p.setStateInformation(original.getData(), static_cast(original.getSize())); + CHECK(std::abs(para->getValue() - before) < 1e-6f); + } + const auto again = stateOf(p); + CHECK(xmlOf(again)->toString() == xmlOf(original)->toString()); + CHECK(again == original); + } +} + +TEST_CASE("the state carries its schema version, and round trips", "[state]") { + PluginProcessor a; + edit(a); + const auto state = stateOf(a); + // the attribute's name and its first value are part of the format + CHECK(xmlOf(state)->getIntAttribute("schema_version") == 1); + CHECK(PluginProcessor::kStateVersion == 1); + + PluginProcessor b; + b.setStateInformation(state.getData(), static_cast(state.getSize())); + checkSameState(a, b); + CHECK(stateOf(b) == state); +} + +TEST_CASE("a state without a schema version, as 0.1.0 saved it, loads as it always has", "[state]") { + // 0.1.0's layout: the parameter tree with the product's version, then the session part, and no schema version + const auto legacy = juce::parseXML(R"( + + + + + +)"); + REQUIRE(legacy != nullptr); + REQUIRE(schema::read(*legacy) == 0); + PluginProcessor p; + load(p, *legacy); + CHECK(std::abs(plain(p, params::kTarget) + 21.f) < 1e-4f); + CHECK(std::abs(plain(p, params::kGuard) - .4f) < 1e-4f); + CHECK(plain(p, params::kLookahead) == 0.f); + CHECK(p.getMode() == dsp::Mode::kRead); + CHECK(std::abs(p.getRide()->get() + 2.5f) < 1e-4f); + CHECK_FALSE(p.parameters_.state.getChildWithName("Session").isValid()); + + // the same as loading what this build saves, less the version + PluginProcessor a; + edit(a); + auto xml = xmlOf(stateOf(a)); + xml->removeAttribute(schema::kAttribute); + PluginProcessor b; + load(b, *xml); + checkSameState(a, b); + CHECK(schema::read(*xmlOf(stateOf(b))) == PluginProcessor::kStateVersion); +} + +TEST_CASE("a state from a newer build loads the parameters and session this build knows, and saves as this build's version", "[state]") { + PluginProcessor a; + edit(a); + auto xml = xmlOf(stateOf(a)); + schema::write(*xml, PluginProcessor::kStateVersion + 1); + // what a later build might add: a parameter, a property, a part and a session setting this build has never heard of + auto* future_param = xml->createNewChildElement("PARAM"); + future_param->setAttribute("id", "future_param"); + future_param->setAttribute("value", 3.0); + xml->setAttribute("future_property", "x"); + xml->createNewChildElement("FutureSection")->setAttribute("future_value", 1); + xml->getChildByName("Session")->setAttribute("future_setting", 1); + + PluginProcessor b; + load(b, *xml); + checkSameState(a, b); + const auto saved = xmlOf(stateOf(b)); + CHECK(schema::read(*saved) == PluginProcessor::kStateVersion); + CHECK_FALSE(saved->toString().contains("future")); + CHECK(stateOf(b) == stateOf(a)); +} + +TEST_CASE("the state restores exactly after every parameter moves, as pluginval checks", "[state]") { + PluginProcessor fresh; + checkRestoresExactly(fresh, 1); + + PluginProcessor edited; + edited.setMode(dsp::Mode::kRead); + juce::Random random(2); + for (auto* para : edited.getParameters()) { + para->setValueNotifyingHost(random.nextFloat()); + } + checkRestoresExactly(edited, 3); +} + +TEST_CASE("factory and user presets carry the schema version and load", "[state]") { + const auto codec = presetCodec(); + for (const auto& [name, text] : factoryPresets()) { + CAPTURE(name); + const auto file = juce::File::createTempFile(".json"); + REQUIRE(file.replaceWithText(text)); + juce::MemoryBlock block; + REQUIRE(codec.read(file, block).wasOk()); + file.deleteFile(); + CHECK(schema::read(*xmlOf(block)) == PluginProcessor::kStateVersion); + PluginProcessor p; + p.setStateInformation(block.getData(), static_cast(block.getSize())); + CHECK(plain(p, params::kTarget) < 0.f); + } + + PluginProcessor a; + edit(a); + const auto file = juce::File::createTempFile(".json"); + REQUIRE(codec.write(file, stateOf(a)).wasOk()); + juce::MemoryBlock block; + REQUIRE(codec.read(file, block).wasOk()); + file.deleteFile(); + CHECK(schema::read(*xmlOf(block)) == PluginProcessor::kStateVersion); + PluginProcessor b; + b.setStateInformation(block.getData(), static_cast(block.getSize())); + for (const auto& id : params::allIDs()) { + CAPTURE(id); + CHECK(std::abs(a.parameters_.getParameter(id)->getValue() - b.parameters_.getParameter(id)->getValue()) < 1e-6f); + } +} diff --git a/products/limiter/CHANGELOG.md b/products/limiter/CHANGELOG.md index 22c87733..b7205add 100644 --- a/products/limiter/CHANGELOG.md +++ b/products/limiter/CHANGELOG.md @@ -11,11 +11,21 @@ - The loudness meter's maximum momentary and short-term loudness are measured every 10 ms instead of every 100 ms. A burst that fell between updates read up to 0.45 LU low (EBU Tech 3341 cases 13 and 14); the momentary and short-term readouts still update ten times a second. +- Restoring a session or preset puts each switch exactly where it was saved. One a host had left between on and off + (0.4 is off) went on reporting that value to the host. + +### Changed + +- Sessions and presets record the layout of the state they were saved in (`schema_version="1"` on the state's root), + so a later version can upgrade them. A state without it, saved before, loads as it always has; one saved by a + newer version loads every setting this version has and leaves the rest. ### Tests - Golden-behaviour tests for the vendored engine (ceiling at every oversampling factor, a gain-reduction fingerprint, recovery per character, channel delta), so an engine sync that changes the sound fails CI. +- State tests: a round trip, a state without a version, one from a newer version, factory and user presets, and the + restore check pluginval makes (every parameter moved, the state set back, the same bytes read back). - The loudness meter against EBU Tech 3341 cases 1–5 and 9–23 and Tech 3342 cases 1–4, at 44.1, 48 and 96 kHz. ## 0.1.0 diff --git a/products/limiter/source/PluginProcessor.cpp b/products/limiter/source/PluginProcessor.cpp index d14d5a15..ad3e96fd 100644 --- a/products/limiter/source/PluginProcessor.cpp +++ b/products/limiter/source/PluginProcessor.cpp @@ -13,9 +13,15 @@ #include #include +#include + #include "ui/editor.hpp" namespace { + // Altvane: upgrades for states older than kStateVersion, in order (version 0, which ZL's releases and Altvane's + // before versions wrote, is version 1) + constexpr std::array kStateMigrations{}; + std::atomic& raw(juce::AudioProcessorValueTreeState& tree, const char* id) { return *tree.getRawParameterValue(id); } @@ -182,6 +188,7 @@ void PluginProcessor::getStateInformation(juce::MemoryBlock& dest_data) { temp_tree.appendChild(parameters_.copyState(), nullptr); temp_tree.appendChild(na_parameters_.copyState(), nullptr); const std::unique_ptr xml(temp_tree.createXml()); + altvane::ui::state_schema::write(*xml, kStateVersion); copyXmlToBinary(*xml, dest_data); } @@ -191,6 +198,7 @@ void PluginProcessor::setStateInformation(const void* data, const int size_in_by return; } + const auto version = altvane::ui::state_schema::upgrade(*xml_state, kStateVersion, kStateMigrations); const auto temp_tree = juce::ValueTree::fromXml(*xml_state); const auto parameter_state = temp_tree.getChildWithName( juce::Identifier(zlstate::schema::kParameterState)); @@ -201,8 +209,14 @@ void PluginProcessor::setStateInformation(const void* data, const int size_in_by return; } - parameters_.replaceState(withDefaults(parameter_state, parameters_, *this)); - na_parameters_.replaceState(withDefaults(non_automatable_state, na_parameters_, dummy_processor_)); + // from a newer build: only the parameters this one has + const auto known = [version](const juce::ValueTree& saved, const juce::AudioProcessorValueTreeState& tree) { + return version > kStateVersion ? altvane::ui::state_schema::knownParameters(saved, tree) : saved; + }; + parameters_.replaceState(withDefaults(known(parameter_state, parameters_), parameters_, *this)); + na_parameters_.replaceState(withDefaults(known(non_automatable_state, na_parameters_), na_parameters_, + dummy_processor_)); + altvane::ui::state_schema::settleSwitches(*this); } int PluginProcessor::wantedOversampling() const { diff --git a/products/limiter/source/PluginProcessor.hpp b/products/limiter/source/PluginProcessor.hpp index 4fc9b114..6bf06b98 100644 --- a/products/limiter/source/PluginProcessor.hpp +++ b/products/limiter/source/PluginProcessor.hpp @@ -28,6 +28,12 @@ */ class PluginProcessor final : public juce::AudioProcessor, private juce::Timer { public: + /** + * the layout of the saved state, sessions and presets alike: raise it only with a migration in + * PluginProcessor.cpp (see altvane_ui/session/state_schema.hpp) + */ + static constexpr int kStateVersion = 1; + zlstate::DummyProcessor dummy_processor_; juce::AudioProcessorValueTreeState parameters_; juce::AudioProcessorValueTreeState na_parameters_; diff --git a/products/limiter/source/ui/factory_presets.cpp b/products/limiter/source/ui/factory_presets.cpp index 24300644..33e6e051 100644 --- a/products/limiter/source/ui/factory_presets.cpp +++ b/products/limiter/source/ui/factory_presets.cpp @@ -9,6 +9,8 @@ #include "factory_presets.hpp" +#include + #include "../PluginProcessor.hpp" #include "common.hpp" #include "model/character.hpp" @@ -132,6 +134,8 @@ namespace altvane::limiter { juce::ValueTree state(zlstate::schema::kProcessorState); state.appendChild(parameters, nullptr); state.appendChild(session, nullptr); + // written in this build's layout, so it says so + state.setProperty(altvane::ui::state_schema::kAttribute, PluginProcessor::kStateVersion, nullptr); result.emplace_back(juce::String("Factory/") + preset.name, zlstate::PresetJson::toText(state)); } return result; diff --git a/products/limiter/tests/state_tests.cpp b/products/limiter/tests/state_tests.cpp new file mode 100644 index 00000000..e9487f44 --- /dev/null +++ b/products/limiter/tests/state_tests.cpp @@ -0,0 +1,182 @@ +// Copyright (C) 2026 - Altvane, based on ZLLimiter by zsliu98 +// This file is part of Altvane Limiter +// +// Altvane Limiter is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License Version 3 as published by the Free Software Foundation. +// +// Altvane Limiter is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License along with Altvane Limiter. If not, see . +#include +#include + +#include + +#include "test_helpers.hpp" +#include "state/preset_json.hpp" +#include "ui/factory_presets.hpp" + +using Catch::Matchers::WithinAbs; +using namespace test; +namespace params = altvane::limiter::params; +namespace schema = altvane::ui::state_schema; + +/* + * The schema version on Altvane's state (see altvane_ui/session/state_schema.hpp); ZL Limiter's own states have none + */ +namespace { + juce::MemoryBlock stateOf(juce::AudioProcessor& p) { + juce::MemoryBlock block; + p.getStateInformation(block); + return block; + } + + std::unique_ptr xmlOf(const juce::MemoryBlock& block) { + auto xml = juce::AudioProcessor::getXmlFromBinary(block.getData(), static_cast(block.getSize())); + REQUIRE(xml != nullptr); + return xml; + } + + void load(juce::AudioProcessor& p, const juce::XmlElement& xml) { + juce::MemoryBlock block; + juce::AudioProcessor::copyXmlToBinary(xml, block); + p.setStateInformation(block.getData(), static_cast(block.getSize())); + } + + void edit(PluginProcessor& p) { + set(p, zlp::PInputGain::kID, 7.5f); + set(p, zlp::POutputCeiling::kID, -1.f); + set(p, zlp::POversampling::kID, 2.f); + set(p, params::PDitherBits::kID, 3.f); + set(p, params::PDCFilter::kID, 1.f); + set(p.na_parameters_, zlstate::PLoudnessTarget::kID, 3.f); + } + + void checkSameParameters(PluginProcessor& a, PluginProcessor& b) { + for (const auto& [from, to] : { + std::pair{&a.parameters_, &b.parameters_}, std::pair{&a.na_parameters_, &b.na_parameters_} + }) { + const auto& owner = from == &a.parameters_ ? static_cast(a) : a.dummy_processor_; + for (auto* para : owner.getParameters()) { + const auto id = dynamic_cast(para)->getParameterID(); + CAPTURE(id); + CHECK_THAT(to->getParameter(id)->getValue(), WithinAbs(para->getValue(), 1e-6)); + } + } + } + + /** + * what pluginval's "Plugin state restoration" does at strictness 10: each automatable parameter but bypass moved, + * the saved state set back (which must restore it), and at the end the state read again must be the one that was + * set, byte for byte + */ + void checkRestoresExactly(juce::AudioProcessor& p, const int seed) { + const auto original = stateOf(p); + juce::Random random(seed); + for (auto* para : p.getParameters()) { + if (!para->isAutomatable() || para == p.getBypassParameter()) { + continue; + } + CAPTURE(para->getName(64)); + const auto before = para->getValue(); + para->setValueNotifyingHost(random.nextFloat()); + p.setStateInformation(original.getData(), static_cast(original.getSize())); + CHECK_THAT(para->getValue(), WithinAbs(before, 1e-6)); + } + const auto again = stateOf(p); + CHECK(xmlOf(again)->toString() == xmlOf(original)->toString()); + CHECK(again == original); + } +} + +TEST_CASE("the state carries its schema version, and round trips", "[state]") { + PluginProcessor a; + edit(a); + const auto state = stateOf(a); + const auto xml = xmlOf(state); + // on ZL Limiter's root; the attribute's name and its first value are part of the format + CHECK(xml->hasTagName(zlstate::schema::kProcessorState)); + CHECK(xml->getIntAttribute("schema_version") == 1); + CHECK(PluginProcessor::kStateVersion == 1); + + PluginProcessor b; + b.setStateInformation(state.getData(), static_cast(state.getSize())); + checkSameParameters(a, b); + CHECK(stateOf(b) == state); +} + +TEST_CASE("a state without a schema version, as 0.1.0 saved it, loads as it always has", "[state]") { + // 0.1.0 saved this build's layout without the version (ZL Limiter's: "a ZL Limiter state sets Altvane's own + // parameters to their defaults") + PluginProcessor a; + edit(a); + auto xml = xmlOf(stateOf(a)); + xml->removeAttribute(schema::kAttribute); + REQUIRE(schema::read(*xml) == 0); + PluginProcessor b; + load(b, *xml); + checkSameParameters(a, b); + CHECK(schema::read(*xmlOf(stateOf(b))) == PluginProcessor::kStateVersion); +} + +TEST_CASE("a state from a newer build loads the parameters this build knows, and saves as this build's version", "[state]") { + PluginProcessor a; + edit(a); + auto xml = xmlOf(stateOf(a)); + schema::write(*xml, PluginProcessor::kStateVersion + 1); + // what a later build might add: parameters, a property and a part this build has never heard of + for (auto* tree : xml->getChildIterator()) { + auto* future_param = tree->createNewChildElement("PARAM"); + future_param->setAttribute("id", "future_param"); + future_param->setAttribute("value", 3.0); + tree->setAttribute("future_property", "x"); + } + xml->setAttribute("future_property", "x"); + xml->createNewChildElement("FutureSection")->setAttribute("future_value", 1); + + PluginProcessor b; + load(b, *xml); + checkSameParameters(a, b); + const auto saved = xmlOf(stateOf(b)); + CHECK(schema::read(*saved) == PluginProcessor::kStateVersion); + CHECK_FALSE(saved->toString().contains("future")); + CHECK(stateOf(b) == stateOf(a)); +} + +TEST_CASE("the state restores exactly after every parameter moves, as pluginval checks", "[state]") { + PluginProcessor fresh; + checkRestoresExactly(fresh, 1); + + PluginProcessor edited; + juce::Random random(2); + for (auto* para : edited.getParameters()) { + para->setValueNotifyingHost(random.nextFloat()); + } + checkRestoresExactly(edited, 3); +} + +TEST_CASE("factory and user presets carry the schema version and load", "[state]") { + PluginProcessor p; + const auto codec = altvane::limiter::presetCodec(p); + for (const auto& [name, text] : altvane::limiter::factoryPresets(p)) { + CAPTURE(name); + const auto file = juce::File::createTempFile(".json"); + REQUIRE(file.replaceWithText(text)); + juce::MemoryBlock block; + REQUIRE(codec.read(file, block).wasOk()); + file.deleteFile(); + CHECK(schema::read(*xmlOf(block)) == PluginProcessor::kStateVersion); + p.setStateInformation(block.getData(), static_cast(block.getSize())); + } + + PluginProcessor a; + edit(a); + const auto file = juce::File::createTempFile(".json"); + REQUIRE(codec.write(file, stateOf(a)).wasOk()); + juce::MemoryBlock block; + REQUIRE(zlstate::PresetJson::read(file, block).wasOk()); + file.deleteFile(); + CHECK(schema::read(*xmlOf(block)) == PluginProcessor::kStateVersion); + PluginProcessor b; + b.setStateInformation(block.getData(), static_cast(block.getSize())); + checkSameParameters(a, b); +}