From b4ed695735ad102d48f277f31a5b6d58bd1c7903 Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Mon, 2 Nov 2026 12:30:40 -0600 Subject: [PATCH 1/2] Version every product's saved state, and restore it exactly - libs/ui session/state_schema: an integer schema_version on the root of each saved state (sessions and presets), a {from, apply} migration table run from the saved version up, and the policy for a state from a newer build: load each parameter this build has by ID (and what the product reads by name, the Leveler's session part), drop the rest, and save as this build's version. No version is version 0, version 1's layout, so older states and ZL's own load unchanged. Every product is at 1 with no migrations. - Each processor writes and reads it; generated factory presets carry it. - Restoring a state now puts each switch (AudioParameterBool) exactly on 0 or 1. A switch keeps the raw value a host wrote (0.48 is off) and replaceState() skips a parameter whose plain value already matches, so the host went on reading 0.48 after an "off" state was restored (EQ, Compressor, Limiter). Done in our wrappers; the vendored engines are untouched. - Leveler: the rider fader is saved as the value its normalised position gives, as the parameter tree stores its parameters. Its default, 0, restored as -5.4e-7 (a step's rounding), so the state didn't save back identically. - Tests per product: a round trip, a state without a version (0.1.0 layout), one from a newer version, factory and user presets, and pluginval's state restoration check (each parameter moved, the state set back, the same bytes read back), which failed on EQ, Compressor, Limiter and Leveler before. Co-Authored-By: Claude Opus 5.5 --- .../altvane_ui/session/state_schema.cpp | 58 +++++ .../altvane_ui/session/state_schema.hpp | 65 ++++++ products/compressor/CHANGELOG.md | 5 + .../compressor/source/PluginProcessor.cpp | 18 +- .../compressor/source/PluginProcessor.hpp | 6 + .../compressor/source/ui/factory_presets.cpp | 4 + products/compressor/tests/state_tests.cpp | 182 +++++++++++++++ products/deesser/CHANGELOG.md | 8 + products/deesser/source/PluginProcessor.cpp | 19 +- products/deesser/source/PluginProcessor.hpp | 6 + .../deesser/source/ui/factory_presets.cpp | 5 +- products/deesser/tests/state_tests.cpp | 219 ++++++++++++++++++ products/dialogue/CHANGELOG.md | 13 ++ products/dialogue/source/PluginProcessor.cpp | 21 +- products/dialogue/source/PluginProcessor.hpp | 6 + .../dialogue/source/ui/factory_presets.cpp | 5 +- products/dialogue/tests/state_tests.cpp | 190 +++++++++++++++ products/eq/CHANGELOG.md | 13 ++ products/eq/source/PluginProcessor.cpp | 18 +- products/eq/source/PluginProcessor.hpp | 6 + products/eq/tests/state_tests.cpp | 149 ++++++++++++ products/leveler/CHANGELOG.md | 10 + products/leveler/source/PluginProcessor.cpp | 18 +- products/leveler/source/PluginProcessor.hpp | 6 + .../leveler/source/ui/factory_presets.cpp | 5 +- products/leveler/tests/state_tests.cpp | 200 ++++++++++++++++ products/limiter/CHANGELOG.md | 10 + products/limiter/source/PluginProcessor.cpp | 18 +- products/limiter/source/PluginProcessor.hpp | 6 + .../limiter/source/ui/factory_presets.cpp | 4 + products/limiter/tests/state_tests.cpp | 182 +++++++++++++++ 31 files changed, 1456 insertions(+), 19 deletions(-) create mode 100644 libs/ui/source/altvane_ui/session/state_schema.cpp create mode 100644 libs/ui/source/altvane_ui/session/state_schema.hpp create mode 100644 products/compressor/tests/state_tests.cpp create mode 100644 products/deesser/tests/state_tests.cpp create mode 100644 products/dialogue/tests/state_tests.cpp create mode 100644 products/leveler/tests/state_tests.cpp create mode 100644 products/limiter/tests/state_tests.cpp 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); +} From bdfdec2b8105c6d1395abb516dc7dd8556e5214c Mon Sep 17 00:00:00 2001 From: Bailey Pumfleet Date: Mon, 2 Nov 2026 12:30:58 -0600 Subject: [PATCH 2/2] Run pluginval's Plugin state restoration test again It was disabled from the first commit, copied from ZL's workflows. Every product passes it now (strictness 10, ZL-Audio pluginval v1.0.4-temp). The Linux Thread Sanitizer job still skips Background thread state. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/cmake_full_test.yml | 2 +- .github/workflows/cmake_heavy_test.yml | 2 +- .github/workflows/sanitizer.yml | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) 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)