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);
+}