C++ в плагине: шаблоны и сериализация параметров

Как устроены шаблоны C++ в коде плагина на JUCE и как на них построено сохранение параметров в JSON?

Эта статья продолжает «C++ в плагине: классы, конструкторы и время жизни» и разбирает шаблоны (templates), которые встречаются в плагине (plug-in)плагин (plug-in) — Программный модуль эффекта или инструмента, который загружается в DAW или другую программу-хост и обрабатывает переданные ему аудиоблоки. тремоло (tremolo)тремоло (tremolo) — Эффект периодического изменения амплитуды аудиосигнала. Скорость пульсации задаёт частота управляющего генератора, а диапазон усиления зависит от глубины и способа её преобразования. из официального курса JUCE: шаблоны функций и классов, параметры-типы и параметры-числа, auto в параметрах, требования к типу-аргументу, вариативные шаблоны и вывод аргументов шаблона класса. Каждое понятие показано на фрагменте готовой версии из папки complete (JUCE 8.0.12, C++23); на полях стоят ссылки на уроки Learn C++ и справочник cppreference.

Второй раздел разбирает целиком один шаблон — serialise, через который плагин сохраняет параметры в JSON и читает их обратно. В нём сходятся правила первого раздела: параметр-тип, требования к типу, вариативный шаблон и пересылающие ссылки.

Шаблоны

Шаблон (template) позволяет один раз описать алгоритм или контейнер для разных типов. По назначению он близок к дженерикам других языков. juce::AudioBuffer<float> — буфер отсчётов (sample)отсчёт (sample) — Значение сигнала в один момент дискретизации, одно число. В звукозаписи его также называют сэмплом. float, AudioBuffer<double> — буфер double; логика класса общая, а тип элементов выбирается при использовании. Компилятор инстанцирует (instantiate) шаблон для каждого набора аргументов, поэтому определение шаблона должно быть видно в месте использования, и шаблонный код обычно лежит в заголовках.

Шаблон принимает и значения: в std::array<juce::dsp::Oscillator<float>, 2u> lfos второй аргумент задаёт число элементов. Плагин объявляет и собственные шаблоны. SampleFifo<SampleType> передаёт отсчёты LFO (low-frequency oscillator, LFO)низкочастотный генератор (low-frequency oscillator, LFO) — Генератор медленно повторяющегося управляющего сигнала, которым изменяют параметры звука, например усиление, частоту или панораму. Общей строгой верхней границы частоты у LFO нет. из аудиопотока (audio thread)аудиопоток (audio thread) — Поток с повышенным приоритетом, который вызывает обработку звука для каждого блока. Каждый вызов должен закончиться до срока блока, поэтому в нём не выделяют память, не берут блокировки и не обращаются к диску. в интерфейс, а StridedQueue<T, Size> хранит точки графика:

C++
template <typename SampleType>
class SampleFifo { /* ... */ };

template <typename T, size_t Size>
class StridedQueue { /* ... */ };

Для функций параметры шаблона обычно выводятся из аргументов:

C++
template <typename Number>
Number twice (Number value)
{
    return value + value;
}

auto integer = twice (3);          // Number выводится как int
auto sample = twice (0.5f);        // Number выводится как float
auto precise = twice<double> (3);  // Number задан явно, аргумент преобразуется в double

Явный аргумент нужен, когда вывести тип не из чего. std::make_unique<juce::AudioParameterFloat>(...) передаёт свои аргументы конструктору, и по ним не видно, какой объект создать. juce::FromVar::convert<SerializableParameters>(parsedResult) получает juce::var, а тип нужен только для результата. Парная ей juce::ToVar::convert(from(parameters)) выводит тип из аргумента. static_cast<int>(x) только похож на шаблон: это встроенная операция языка.

Шаблон класса изнутри

SampleFifo целиком, вместе с телами методов, объявлен в заголовке SampleFifo.h. Имя SampleType внутри класса обозначает тип, который подставят при использовании. Отдельной строки template перед методами нет: методы, определённые внутри шаблона класса, пользуются его параметрами.

C++
// SampleFifo.h, сокращено
template <typename SampleType>
class SampleFifo {
public:
  void push(SampleType sample) { /* ... */ }
  void popAll(juce::AudioBuffer<SampleType>& bufferToFill) { /* ... */ }
  // ...

private:
  static constexpr auto initialCapacity = 1024;
  juce::AbstractFifo fifo{initialCapacity};
  juce::AudioBuffer<SampleType> buffer{1, initialCapacity};
};

Поле SampleFifo<float> lfoSampleFifo; в классе Tremolo заставляет компилятор создать класс, где SampleType заменён на float: метод push(float), поле juce::AudioBuffer<float> buffer.

Второй параметр StridedQueue — не тип, а число (non-type template parameter). Size передаётся дальше в std::array<T, Size>, поэтому число точек графика становится частью типа, а массив лежит внутри объекта, без выделения памяти в куче (heap)куча (heap) — Динамическая память для объектов, созданных выражением new. Такой объект живёт до явного delete или до удаления его владельцем, например умным указателем..

C++
// detail/StridedQueue.h, сокращено
template <typename T, size_t Size>
class StridedQueue {
  // ...
private:
  std::array<T, Size> stridedElements{};
  size_t elementIndex{0u};
  size_t stride{1u};
};

Значение такого параметра должно быть известно при компиляции. В LfoVisualizer оно объявлено как static constexpr auto pointsOnPath = 22050u; и передано в поле detail::StridedQueue<float, pointsOnPath> lfoSamplesToPlot;. Обычная переменная на этом месте не подойдёт:

C++
auto points = 22050u;
detail::StridedQueue<float, points> queue;  // ошибка: points не константа времени компиляции

Каждый набор аргументов даёт отдельный класс. Тест берёт StridedQueue<int, 5>: на пяти целых проще проверить, какие элементы остались в очереди. StridedQueue<int, 5> и StridedQueue<float, 22050> — два не связанных между собой типа, присвоить объект одного другому нельзя. В тесте testee.pushBack(std::vector{1, 0, 0, 2, ...}) компилятор выводит std::vector<int>, и вектор неявно превращается в параметр std::span<const int>. Запись T(0) в методе pushBackZeros даёт ноль нужного типа: 0.0f для float, 0 для int.

Почему detail::StridedQueue<float, points> не компилируется, если points — обычная переменная auto points = 22050u?

Size — параметр шаблона, и его значение входит в тип: от него зависит размер поля std::array<T, Size>, то есть размер всего объекта. Тип должен быть известен при компиляции, а значение обычной переменной существует только во время выполнения. Подходит константа constexpr, как pointsOnPath, или литерал.

Типы и функции в аргументах

Аргументом шаблона бывает и тип без данных, который выбирает поведение. Во втором аргументе juce::SmoothedValue<float, juce::ValueSmoothingTypes::Linear> пустая структура Linear задаёт линейное сглаживание: значение меняется на одну и ту же величину на каждом отсчёте. Multiplicative выбрала бы изменение в одно и то же число раз. BypassTransitionSmoother пишет короче, juce::LinearSmoothedValue<float>. Это шаблон-псевдоним (alias template) того же класса:

C++
// juce_SmoothedValue.h
template <typename FloatType>
using LinearSmoothedValue = SmoothedValue <FloatType, ValueSmoothingTypes::Linear>;

juce::MathConstants<float>::halfPi — статическая константа шаблона структуры: внутри записано static constexpr FloatType pi = static_cast<FloatType> (3.141592653589793238L); и так же для других констант. Аргумент float даёт π сразу в нужном типе, и в выражении с float нет перехода к double.

Аргументом бывает и тип функции:

C++
// LfoVisualizer.h
using ReadAllLfoSamples = std::function<void(juce::AudioBuffer<float>&)>;

Запись void(juce::AudioBuffer<float>&) описывает функцию, которая ничего не возвращает и принимает ссылку на буфер. Так LfoVisualizer объявляет, какой вызов ему нужен от процессора, и лямбды из PluginEditor подходят под эту сигнатуру. juce::dsp::Oscillator<float> хранит генератор формы волны так же, в std::function<float (float)>. Поэтому лямбда [](auto phase) { ... } из Tremolo.h при создании осциллятора получает параметр типа float.

auto в параметрах

Функция addParameterToProcessor из Parameters.cpp объявлена с параметром auto parameter. С C++20 это сокращённая запись шаблона функции (abbreviated function template): каждый параметр auto становится отдельным параметром шаблона. Её объявление равносильно такому:

C++
template <typename Pointer>
auto& addParameterToProcessor (juce::AudioProcessor& processor, Pointer parameter);

Три функции create…Parameter передают в неё std::unique_ptr<juce::AudioParameterFloat>, std::unique_ptr<juce::AudioParameterBool> и std::unique_ptr<juce::AudioParameterChoice>. Компилятор создаёт три версии функции, и тип результата auto& в каждой выводится из return result;: AudioParameterFloat&, AudioParameterBool&, AudioParameterChoice&. Лямбда с параметром auto (C++14) устроена так же: шаблоном становится её оператор вызова, о чём сказано в разделе «Лямбды» статьи о стандартной библиотеке.

Требования к типу

Шаблон не перечисляет, что должен уметь тип-аргумент. Требования следуют из тела: что тело вызывает у параметра, то у типа и должно быть. Компилятор проверяет это при инстанцировании, для конкретного типа:

C++
// Шаблон требует от Meter метод getLevel(), но нигде этого не объявляет
template <typename Meter>
float readLevel (const Meter& meter)
{
    return meter.getLevel();
}

struct PeakMeter
{
    float getLevel() const { return peak; }

    float peak = 0.5f;
};

auto level = readLevel (PeakMeter {});  // компилируется: метод есть
auto wrong = readLevel (0.5f);          // ошибка: у float нет getLevel()

Пока шаблон не использован, выражение meter.getLevel() полностью не проверяется: тип Meter неизвестен, и смысл выражения зависит от него (dependent name). Ошибка из последней строки указывает на return meter.getLevel(); внутри шаблона, а строки in instantiation of в сообщении компилятора ведут к вызову, который её вызвал. Это похоже на утиную типизацию Python, только несоответствие обнаруживается при сборке. В Go такие требования записывают интерфейсом. В C++20 для этого есть концепты (concepts), но код курса и сериализация JUCE их не используют. По этому правилу компилируется и archive.getVersion() в методе serialise из раздела «Сохранение параметров».

Вариативные шаблоны, CTAD и span

Функция juce::ignoreUnused, о которой шла речь в «Основах C++ для JUCE», написана как вариативный шаблон (C++11): пакет параметров typename... Types принимает любое число аргументов любых типов. Тело у неё пустое:

C++
template <typename... Types>
void ignoreUnused(Types&&...) noexcept {}

С C++17 компилятор выводит и аргументы шаблона класса (class template argument deduction, CTAD). Поэтому в LfoVisualizer.cpp записано std::span{...} без <const float>:

C++
lfoSamplesToPlot.pushBack(std::span{
    buffer.getReadPointer(0), static_cast<size_t>(buffer.getNumSamples())});

std::span (C++20) — «вид» на непрерывный кусок памяти: указатель и длина, без владения данными. Это аналог среза в Go без возможности расти или memoryview в Python. Через него в pushBack передаются указатель на отсчёты канала и их число, а сами отсчёты не копируются.

Сохранение параметров: шаблон serialise

Хост сохраняет состояние плагина в проекте: вызывает getStateInformation, и PluginProcessor передаёт параметры в JsonSerializer::serialize. Тот записывает их в JSON такого вида (ожидаемый результат из теста JsonSerializerTest.cpp):

JSON
{
  "__version__": 1,
  "pluginName": "Tremolo",
  "modulationRateHz": 10.0,
  "bypassed": true,
  "modulationWaveform": "Triangle"
}

Преобразование в JSON и обратно выполняет JUCE, но список полей ей сообщает сам тип. В JsonSerializer.cpp для этого объявлена структура SerializableParameters:

C++
// JsonSerializer.cpp
struct SerializableParameters {
  float rate;
  bool bypassed;
  juce::String waveform;

  static constexpr auto marshallingVersion = 1;

  template <typename Archive, typename T>
  static void serialise(Archive& archive, T& p) {
    using namespace juce;

    if (archive.getVersion() != 1) {
      return;
    }

    std::string pluginName = TREMOLO_PLUGIN_NAME;

    archive(named("pluginName", pluginName));

    if (pluginName != TREMOLO_PLUGIN_NAME) {
      return;
    }

    archive(named("modulationRateHz", p.rate), named("bypassed", p.bypassed),
            named("modulationWaveform", p.waveform));
  }
};

Отдельная структура нужна, потому что Parameters хранит ссылки на объекты параметров процессора, а в JSON попадают значения: число, флаг и название формы волны. Функция from() копирует их в SerializableParameters, и результат передаётся в JUCE:

C++
// JsonSerializer::serialize
const auto json = juce::ToVar::convert(from(parameters));

// JsonSerializer::deserialize
const auto parsedParameters =
    juce::FromVar::convert<SerializableParameters>(parsedResult);

Кто вызывает serialise

Ни одна строка плагина не вызывает serialise по имени: его вызывает JUCE внутри ToVar::convert и FromVar::convert. Документация juce::SerialisationTraits описывает договор: у типа есть константа marshallingVersion и либо один шаблон serialise (Archive&, Item&), либо пара load и save. Договор можно выполнить и снаружи типа, специализацией шаблона juce::SerialisationTraits<T>; так JUCE сама поддерживает std::vector, std::optional, std::string и juce::StringArray. Такая схема, в которой тип перечисляет свои поля, а разные архивы выполняют с ними разные операции, — паттерн «посетитель»; он разобран в статье «Паттерны проектирования в JUCE».

Наличие этих членов JUCE проверяет при компиляции. Она пробует вызвать T::serialise с пробным архивом juce::detail::DummyArchive и через if constexpr (C++17) выбирает подходящую ветку; сам вызов при этом не выполняется. Если у типа нет ни marshallingVersion, ни функций сериализации, сборка остановится на static_assert с сообщением No serialisation function found or marshallingVersion unset.

Archive и T: одна функция для записи и чтения

JUCE подставляет параметры шаблона serialise по-разному в зависимости от направления. Типы и значения ниже проверены на JUCE 8.0.12.

  • Сохранение, ToVar::convert. Archive — juce::ToVar::Visitor, T — const SerializableParameters. getVersion() возвращает marshallingVersion, то есть 1; archive(named(...)) добавляет свойство в объект JSON.
  • Загрузка, FromVar::convert<SerializableParameters>. Archive — juce::FromVar::Visitor, T — SerializableParameters. getVersion() возвращает значение "__version__" из JSON или std::nullopt; archive(named(...)) читает свойство и записывает его в поле.
  • Проверка договора при компиляции. Archive — juce::detail::DummyArchive, T — SerializableParameters; вызов не выполняется.

Visitor — закрытые вложенные классы ToVar и FromVar, в своём коде их имя не написать. Шаблону serialise имя и не нужно: он примет любой тип, у которого есть getVersion() и оператор вызова, по правилу из раздела «Требования к типу».

Параметр T нужен из-за константности. При сохранении JUCE передаёт объект по const&, ведь запись в JSON не должна его менять. При загрузке объект передаётся по обычной ссылке, чтобы архив заполнил поля. Параметр типа SerializableParameters& не принял бы константный объект, а шаблон по T принимает оба варианта, и одно тело описывает и запись, и чтение.

named — шаблон функции JUCE с двумя перегрузками. Обе возвращают Named<T>: структуру из имени std::string_view name и ссылки T& value. Значение при этом не копируется.

C++
// juce_Serialisation.h
template <typename T> constexpr auto named (std::string_view c, T& t)       { return Named<T>       { c, t }; }
template <typename T> constexpr auto named (std::string_view c, const T& t) { return Named<const T> { c, t }; }

При сохранении p.rate — поле константного объекта, и подходит вторая перегрузка: получается Named<const float>. При загрузке выбирается первая, Named<float>, и через ссылку архив записывает прочитанное число прямо в поле.

getVersion()

getVersion() — метод класса архива: оба Visitor объявляют std::optional<int> getVersion() const. При сохранении он возвращает версию, с которой пишется объект, то есть marshallingVersion, и ToVar записывает её в JSON как "__version__": 1. При загрузке метод возвращает значение "__version__" из JSON, а если такого свойства нет — std::nullopt.

archive.getVersion() != 1 сравнивает std::optional<int> с числом. Результат true, если значения нет или оно отличается от 1, и тогда serialise выходит, не читая полей. FromVar::convert всё равно возвращает объект: его поля остаются нулевыми, waveform — пустой строкой. deserialize отклоняет такой результат, потому что пустого названия нет в списке форм волны, и параметры плагина не меняются.

archive(a, b, c)

C++
// juce_JSONSerialisation.h, ToVar::Visitor и FromVar::Visitor
template <typename... Ts>
void operator() (Ts&&... ts)
{
    (visit (std::forward<Ts> (ts)), ...);
}
  • typename... Ts — пакет параметров: вызов с одним аргументом даёт один тип, с тремя — три.
  • Ts&& у параметра шаблона — пересылающая ссылка (forwarding reference). Она принимает и переменную, и временный объект. Результат named(...) временный, и обычная ссылка Ts& к нему не привязалась бы.
  • (visit (std::forward<Ts> (ts)), ...) — выражение свёртки (fold expression, C++17) по оператору «запятая». Компилятор разворачивает его в visit(a), visit(b), visit(c), и вызовы выполняются слева направо. std::forward передаёт каждый аргумент дальше в том виде, в каком он пришёл: временный остаётся временным.

visit перегружен для разных аргументов. Для Named<T> версия ToVar добавляет в объект JSON свойство с именем name и значением value. Версия FromVar ищет свойство с этим именем и записывает его в value; если свойства нет или его тип не подходит, загрузка помечается неудачной, и convert возвращает пустой std::optional. Поэтому archive(a, b, c) делает то же, что archive(a); archive(b); archive(c);, а свойства в JSON идут в порядке аргументов.

Переменная pluginName работает в обе стороны. При сохранении архив пишет в JSON её начальное значение — название плагина из макроса TREMOLO_PLUGIN_NAME, который задаёт CMake («Макросы из CMake»). При загрузке архив заменяет значение переменной прочитанным из JSON. Если состояние сохранил другой плагин, названия не совпадут, serialise выйдет до чтения параметров, и deserialize отклонит результат, как при чужой версии.

Почему serialise принимает T&, а не SerializableParameters&?

При сохранении JUCE передаёт объект как константный, и T выводится как const SerializableParameters. Ссылка SerializableParameters& к константному объекту не привязывается. Шаблон принимает и константный объект при записи, и изменяемый при чтении, поэтому одна функция описывает оба направления.

Что вернёт archive.getVersion() при загрузке JSON без свойства __version__, и что станет с параметрами плагина?

std::nullopt. Сравнение std::nullopt != 1 истинно, и serialise выходит, не читая полей. FromVar::convert вернёт объект с нулевой частотой и пустым названием формы волны, deserialize не найдёт пустое название в списке и вернёт ошибку, не меняя параметры.

Посетитель и стандартная библиотека

Схема, в которой serialise перечисляет поля, а архивы ToVar и FromVar выполняют с ними разные операции, — паттерн «посетитель»; он разобран в статье «Паттерны проектирования в JUCE». Типы стандартной библиотеки из этой статьи — std::optional, std::function и лямбды — подробно описаны в «Стандартной библиотеке C++ в плагине».

Источники

Связи

Поиск

Ищем по названиям, тексту статей и английским терминам. Например: «частота дискретизации», «Nyquist», «аналоговый сигнал».