Код учебного плагина (plug-in)плагин (plug-in) — Программный модуль эффекта или инструмента, который загружается в DAW или другую программу-хост и обрабатывает переданные ему аудиоблоки. тремоло (tremolo)тремоло (tremolo) — Эффект периодического изменения амплитуды аудиосигнала. Скорость пульсации задаёт частота управляющего генератора, а диапазон усиления зависит от глубины и способа её преобразования. из официального курса JUCE написан на C++23 и местами выглядит непривычно: макросы вместо констант, пустые функции для «использования» параметра, static_cast на каждом шагу. Эта статья разбирает язык ровно в том объёме, который нужен, чтобы читать этот код и дописывать его. Каждое понятие показано на строке из этого плагина, а за подробностями ведут заметки на полях со ссылками на учебник Learn C++ и справочник cppreference.
Фрагменты взяты из готовой версии плагина в папке complete репозитория tremolo-juce-course (JUCE 8.0.12). Они приведены в оформлении репозитория, а собственные примеры сайта — в стиле JUCE. Короткие учебные примеры без JUCE проверены компиляцией: Apple clang 17, режим C++23.
C++ развивается стандартами: C++98, C++11, C++14, C++17, C++20, C++23. Рядом с возможностями, которые появились после 1998 года, в скобках указан стандарт. Плагин собирается в режиме C++23, поэтому доступно всё перечисленное.
Классы, конструкторы, наследование и время жизни объектов (object lifetime)время жизни объекта (object lifetime) — Промежуток от окончания конструктора до начала деструктора, в который к объекту можно обращаться. Зависит от того, где объект создан: в блоке, внутри другого объекта или в куче. разобраны во второй части, «C++ в плагине: классы, конструкторы и время жизни», шаблоны — в «C++ в плагине: шаблоны и сериализация параметров». Владение объектами (ownership)владение объектом (ownership) — Ответственность за удаление объекта. Владелец удаляет объект, наблюдатели только пользуются им через указатель или ссылку и должны перестать обращаться к нему до удаления. в куче (heap)куча (heap) — Динамическая память для объектов, созданных выражением new. Такой объект живёт до явного delete или до удаления его владельцем, например умным указателем. — в отдельной статье «Кто владеет объектом», а правила для аудиопотока (audio thread)аудиопоток (audio thread) — Поток с повышенным приоритетом, который вызывает обработку звука для каждого блока. Каждый вызов должен закончиться до срока блока, поэтому в нём не выделяют память, не берут блокировки и не обращаются к диску. — в «C++ в аудиопотоке».
Как код попадает в компилятор
Перед компиляцией каждый файл проходит через препроцессор (preprocessor). Он обрабатывает строки, которые начинаются с #, и работает с текстом: типов и областей видимости он не знает. Директива #include вставляет содержимое другого файла на место директивы, как копирование текста. Импорт модулей, как в Python или Go, появился только в C++20, и код плагина его не использует.
#include <juce_core/juce_core.h> // угловые скобки: системные пути и пути библиотек
#include "tremolo_plugin.h" // кавычки: сначала рядом с текущим файломФайлы стандартной библиотеки подключаются без расширения: <vector>, <memory>, <ranges>, <cmath>. Заголовок, который может попасть в одну единицу компиляции дважды, начинается с #pragma once. Строка гарантирует, что файл будет вставлен один раз. Раньше для этого писали «стражи включения» #ifndef, #define, #endif; #pragma once в стандарт не входит, но поддерживается всеми основными компиляторами.
Объявления («что существует») принято писать в заголовок .h, определения («как работает») — в файл реализации .cpp. В PluginProcessor.h объявлен метод getName(), а его тело находится в PluginProcessor.cpp. Каждый .cpp вместе со всем, что он подключил, компилируется отдельно и называется единицей трансляции (translation unit). Затем компоновщик (linker) собирает единицы в один бинарный файл.
Модуль JUCE и «unity build»
Заголовки из include/Tremolo/ не подключают JUCE, а файлы из source/ не подключают свои заголовки. Это работает благодаря формату модуля JUCE. Заголовок tremolo_plugin.h подключает JUCE, стандартную библиотеку и все заголовки проекта в нужном порядке:
// tremolo_plugin.h
#include <juce_dsp/juce_dsp.h>
#include <vector>
#include <memory>
// ...
#include "include/Tremolo/Parameters.h"
#include "include/Tremolo/Tremolo.h"
#include "include/Tremolo/PluginProcessor.h"А tremolo_plugin.cpp подключает этот заголовок и затем вставляет через #include все файлы реализации:
// tremolo_plugin.cpp
#include "tremolo_plugin.h"
#include <TremoloPluginAssets.h>
#include "source/LfoVisualizer.cpp"
#include "source/Parameters.cpp"
#include "source/PluginProcessor.cpp"В итоге весь плагин компилируется как одна единица трансляции. Такой приём называют unity build. Для него важен порядок #include в tremolo_plugin.h: класс должен быть объявлен выше места, где его используют. Например, Tremolo.h стоит раньше PluginProcessor.h, который хранит поле типа Tremolo. А новый заголовок или .cpp нужно добавить в tremolo_plugin.h или tremolo_plugin.cpp, иначе компилятор его не увидит.
Unity build применяют ради скорости полной сборки: тяжёлые заголовки вроде juce_dsp.h компилятор разбирает один раз, а не в каждом .cpp. Модулю JUCE приём нужен ещё и для подключения: модуль добавляется в проект одним .cpp-файлом, который внутри вставляет остальные, и системе сборки не нужен список его исходников. Правка любого файла перекомпилирует всю единицу трансляции, поэтому после мелкого изменения сборка идёт дольше, чем при раздельной компиляции. Имена с внутренней связью и макросы из одного файла видны в следующих (см. анонимное пространство имён). Файл может компилироваться только потому, что нужный ему заголовок подключён выше в общей единице, и вне её он перестанет собираться.
Обычная схема — раздельная компиляция: каждый .cpp сам подключает нужные заголовки, и правка одного файла пересобирает только его. Повторный разбор общих заголовков в такой схеме сокращают предкомпилированные заголовки (precompiled headers), в CMake их задаёт команда target_precompile_headers (CMake 3.16). Склеивать исходники CMake тоже умеет сам: свойство цели UNITY_BUILD (CMake 3.16) объединяет файлы в пакеты во время сборки, а исходный код остаётся пригодным для раздельной компиляции. Модули C++20 (import вместо #include) решают ту же задачу средствами языка: интерфейс модуля компилируется один раз, а макросы импортирующего файла на него не влияют.
Макросы из CMake
const juce::String PluginProcessor::getName() const {
return TREMOLO_PLUGIN_NAME;
}TREMOLO_PLUGIN_NAME нигде в C++ не определён. Его передаёт компилятору CMake: в CMakeLists.txt проекта стоит TREMOLO_PLUGIN_NAME="${TREMOLO_PLUGIN_NAME}" в target_compile_definitions, что равносильно флагу -DTREMOLO_PLUGIN_NAME="Tremolo". Препроцессор заменит имя строкой "Tremolo".
Комментарии
// однострочный комментарий
/* многострочный
комментарий */
/** Комментарий документации (Doxygen).
* @code ... @endcode — пример кода внутри документации
*/Стандарт языка знает только // и /* */. Форма /** */ обычный комментарий для компилятора, но генератор документации Doxygen и среды разработки считают её описанием следующего объявления. Команды @param, @return, @code относятся к разметке Doxygen. В этой форме написано описание класса BypassTransitionSmoother. Комментарий после закрывающей скобки, например } // namespace tremolo, подсказывает, какой блок закрыт; это соглашение стиля, и clang-format умеет его проверять.
Типы и литералы
Таблица 1 показывает встроенные типы, которые чаще всего встречаются в коде плагина. Отсчёты (sample)отсчёт (sample) — Значение сигнала в один момент дискретизации, одно число. В звукозаписи его также называют сэмплом. аудио хранятся во float: для звука хватает точности, а данных вдвое меньше, чем с double. Частота дискретизации (sample rate)частота дискретизации (sample rate) — Число отсчётов в каждом канале за одну секунду, то же число кадров аудиоданных в секунду. Измеряется в герцах: 44 100 Гц означает 44 100 отсчётов на канал в секунду. и время — в double. Подробнее о представлении отсчётов — в статье «Аудиоданные».
| Тип | Что это | Пример из кода |
|---|---|---|
int | целое со знаком, обычно 32 бита | int expectedMaxFramesPerBlock |
float | число с плавающей точкой, обычно 32 бита | juce::AudioBuffer<float> |
double | число с плавающей точкой, обычно 64 бита | double sampleRate |
bool | true или false | bool acceptsMidi() const |
void | значения нет | void releaseResources() |
std::size_t
size_t не встроенный тип и не ключевое слово. Это имя беззнакового целого типа, выбранного реализацией. Полное имя std::size_t объявлено в заголовке <cstddef>. Тип предназначен для размеров объектов и индексов контейнеров стандартной библиотеки. В плагине он нужен на границе с JUCE: библиотека возвращает количество отсчётов как int, а std::vector и std::span ждут size_t.
lfoSamples.resize(4u * static_cast<size_t>(expectedMaxFramesPerBlock));Преобразование само знак не проверяет: отрицательное число, превращённое в size_t, станет огромным положительным. Поэтому оно записано явно, о чём подробнее в разделе про static_cast.
Литералы
Тип числового литерала задаёт его запись: 10 — int, 1.0 — double, 1.0f и 0.4f — float, 4u — unsigned int, 1uz (C++23) — size_t; последний встречается в StridedQueue.h. Суффикс f важен: без него литерал 0.4 имеет тип double, и при передаче в float компилятор предупредит о сужении. Его пишут даже у нуля: 0.f — то же, что 0.0f.
Строка в кавычках, например "Tremolo", не объект-строка, как в Python, а массив char с нулевым байтом на конце. Объекты строк — это классы std::string и juce::String; из литерала они создаются неявно. Сырой литерал R"(...)" (C++11) отключает экранирование и сохраняет переносы строк, с префиксом u8 — в кодировке UTF-8. Так в тестах плагина записан ожидаемый JSON:
const juce::String expectedOutput =
u8R"({
"__version__": 1,
"pluginName": "Tremolo",
"modulationRateHz": 10.0,
"bypassed": true,
"modulationWaveform": "Triangle"
})";Нулевой указатель обозначает nullptr (C++11). Раньше писали NULL или 0, но это целое число, и оно путалось с перегрузками функций. У nullptr собственный тип, и приводится он только к указателям: setLookAndFeel(nullptr) в деструкторе редактора.
auto, const и constexpr
auto (C++11) просит компилятор вывести тип переменной из выражения справа. Это не динамическая типизация: тип остаётся статическим и фиксируется при компиляции. Особенность в том, что auto отбрасывает ссылку и const верхнего уровня. Чтобы получить ссылку, пишут auto&; без амперсанда будет копия.
for (auto& lfo : lfos) { // ссылка на каждый осциллятор в массиве
lfo.reset();
}Если в этом цикле написать auto lfo, reset() сработает на копии осциллятора (oscillator)осциллятор (oscillator) — Генератор повторяющегося колебания. Частота задаёт число повторений за секунду, а форма волны — изменение значения внутри одного периода., а исходный не изменится.
const запрещает менять объект после инициализации. В коде плагина почти всё неизменяемое помечено const:
const auto inputSample = buffer.getSample(channelIndex, frameIndex);
const auto outputSample = modulationValue * inputSample;С указателями const читают справа налево. const auto* const name = "text"; означает: name — константный указатель (второй const) на константные символы (первый). Первый const запрещает менять символы через указатель, второй — перенаправлять сам указатель.
constexpr (C++11) требует, чтобы значение было известно во время компиляции. Его можно использовать там, где нужна константа: размер массива, параметр шаблона. constexpr подразумевает const. Так записаны, например, static constexpr auto modulationDepth = 0.4f; в Tremolo и constexpr auto versionHint = 1; при создании параметров.
Операторы и приведение типов
Условный оператор условие ? значение_если_да : значение_если_нет — выражение, а не инструкция. Через него выбирают целевое значение и подпись кнопки:
const auto target = bypass ? 1.0f : 0.0f;
bypassButton.setButtonText(bypassButton.getToggleState() ? "Bypassed" : "Bypass");Логические операторы && («и»), || («или») и ! («не») вычисляются лениво: если результат ясен по левой части, правая не выполняется. В isBusesLayoutSupported оператор != сравнивает два объекта juce::AudioChannelSet; он работает потому, что класс сам определил этот оператор (см. перегрузку операторов).
static_cast
static_cast<T>(x) — ключевое слово C++: явное преобразование выражения x к типу T. Угловые скобки похожи на шаблон, но это встроенная операция языка, и подключать для неё ничего не нужно. Компилятор проверяет, что такой переход между типами разрешён: static_cast<int>(3.7) допустим и даёт 3, а превратить указатель в несвязанный тип указателя нельзя. Сохранение значения при этом не гарантируется: возможна потеря дробной части, сужение или превращение отрицательного числа в беззнаковое.
dryBuffer.setSize(static_cast<int>(spec.numChannels),
static_cast<int>(spec.maximumBlockSize));В коде плагина static_cast встречается часто, потому что строгие предупреждения (см. раздел о предупреждениях) запрещают молча смешивать знаковые и беззнаковые числа. JUCE хранит размеры как int, а стандартная библиотека ждёт size_t. Приведение в стиле C, (int)x, допускает более широкий набор преобразований и хуже показывает намерение, поэтому в современном коде его избегают. Другие виды приведений в плагине не используются: const_cast снимает const, reinterpret_cast переинтерпретирует биты, dynamic_cast проверяет тип в иерархии классов во время выполнения.
Пространства имён
Пространство имён (namespace) группирует имена, чтобы они не конфликтовали с чужими. Код плагина лежит в tremolo, JUCE — в juce, стандартная библиотека — в std. Оператор разрешения области :: отделяет имя от области, где оно объявлено. В отличие от пакетов Go, пространство имён не связано с файлами: его можно открывать в любом числе файлов, содержимое объединяется.
juce::AudioBuffer<float> // класс AudioBuffer из пространства juce
juce::dsp::AudioBlock<float> // вложенное пространство juce::dsp
std::views::iota (0, n) // функция из std::views
Tremolo::LfoWaveform::sine // значение перечисления внутри класса
PluginProcessor::getName() // метод класса в определении вне классаПространство имён можно вложить в другое: juce::dsp — часть juce. Запись namespace tremolo::assets { ... } (C++17) равна двум вложенным блокам. Поиск имени идёт изнутри наружу: в PluginEditor.cpp написано assets::Background_png, хотя сгенерированные данные лежат в tremolo::assets. Код редактора находится внутри namespace tremolo, поэтому assets находится относительно него.
Анонимное пространство имён
Блок namespace { ... } без имени прячет объявления внутри единицы трансляции. Так устроены вспомогательные функции в Parameters.cpp: addParameterToProcessor и три функции create…Parameter нужны только этому файлу. У таких имён внутренняя связь (internal linkage): другой .cpp может объявить собственную функцию с тем же именем, и компоновщик их не спутает.
В этом проекте нужна оговорка. Из-за unity build все файлы source/*.cpp составляют одну единицу трансляции, поэтому анонимные пространства из разных файлов объединяются. Две вспомогательные функции с одинаковым именем в разных файлах всё равно будут конфликтовать. В заголовке такое пространство создаёт отдельные копии в каждой единице, поэтому для общего интерфейса оно не подходит.
Функции
Тип результата пишется перед именем. В определении вне класса имя метода уточняют именем класса. Компилятор должен увидеть объявление функции до её вызова, поэтому и нужны заголовки:
// PluginProcessor.h — объявление (сигнатура)
bool acceptsMidi() const override;
// PluginProcessor.cpp — определение (тело)
bool PluginProcessor::acceptsMidi() const {
return false;
}В объявлении имена параметров можно опустить, важны только типы: void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&);. Если параметр в теле не нужен, имя можно опустить и в определении. Если тело функции ещё не написано, JUCE предлагает вызвать juce::ignoreUnused(index);: пустая функция «использует» аргумент, и компилятор не предупреждает о неиспользуемой переменной. Стандартная альтернатива (C++17) — атрибут [[maybe_unused]] int index.
Параметр может иметь значение по умолчанию: explicit BypassTransitionSmoother(double crossfadeLengthSecondsValue = 0.01). Если аргумент не передан, подставится 0.01. Выражение по умолчанию вычисляется заново при каждом вызове без аргумента. В Python иначе: значение по умолчанию создаётся один раз при определении функции, поэтому список из def f(items=[]) общий для всех вызовов и накапливает элементы между ними. Задают значение по умолчанию только в объявлении.
Перегрузка
В C++ несколько функций могут иметь одно имя и разные списки параметров; компилятор выбирает подходящую по типам аргументов. У каждой перегрузки своё тело. У juce::AudioProcessor два метода processBlock: для AudioBuffer<float>& и для AudioBuffer<double>&.
int twice (int value) { return value * 2; }
double twice (double value) { return value * 2.0; }
// twice (3) вызовет первое тело, twice (3.5) — второе.В TypeScript перегрузка сигнатур устроена иначе: несколько записей описывают допустимые вызовы, а реализация одна и разветвляется внутри. В C++ ветвление выполняет компилятор при выборе функции.
inline и цепочки вызовов
В тестах плагина функция getFileOutputPath определена в заголовке TestUtils.h со словом inline. Заголовок подключается в несколько .cpp, и без inline в каждой единице трансляции появилось бы своё определение: компоновщик выдал бы ошибку «multiple definition». inline разрешает одинаковые определения в разных единицах и велит оставить одно. Исторически слово означало совет «встроить тело в место вызова», но сейчас главный смысл именно такой. Методы, тело которых написано прямо в классе, как в Tremolo.h, неявно inline.
Та же функция показывает цепочку вызовов: каждый метод возвращает объект, у которого вызывается следующий.
return juce::File::getSpecialLocation(
juce::File::SpecialLocationType::currentExecutableFile)
.getParentDirectory()
.getChildFile(fileName)
.getFullPathName()
.toStdString();Так же построены BusesProperties().withInput(...).withOutput(...) в конструкторе процессора. Шаблон называют «строитель» (builder).
Значение, ссылка и указатель
Это главное отличие C++ от Python, Go и TypeScript: автор кода выбирает, как передаётся объект. В Python и TypeScript объект всегда передаётся по ссылке на него. В Go структура по умолчанию копируется целиком. В C++ копируется любой объект, даже большой класс, если не сказано иное.
Таблица 2 сводит способы передачи с примерами из кода плагина. Обычно маленький тип (int, double, bool) передают по значению, входной объект — как const T&, а объект, который функция меняет, — как T&.
| Способ | Что получает функция | Пример из кода |
|---|---|---|
| по значению | копию аргумента | void setBypass(bool bypass) noexcept |
T& | другое имя того же объекта; функция может менять объект через это имя | void process(juce::AudioBuffer<float>& buffer) noexcept |
const T& | другое имя того же объекта; менять объект через это имя функция не может | void prepare(const juce::dsp::ProcessSpec& spec) |
T* | адрес объекта, может быть пустым | juce::AudioProcessorEditor* createEditor() override |
Ссылка T&
Ссылка — другое имя существующего объекта, изменения через неё меняют оригинал. В отличие от указателя, ссылка обязана быть инициализирована при объявлении, не бывает пустой, не может быть перенаправлена на другой объект и используется как обычная переменная: buffer.getNumSamples(), а не buffer->getNumSamples(). Структура Parameters хранит параметры как ссылки: juce::AudioParameterFloat& rate;.
Ссылку можно и вернуть из функции. Например, процессор плагина хранит параметры в поле parameters, а редактору отдаёт их методом getParameterRefs(). Рядом объявлен метод getBypassParameter(), который возвращает указатель:
// PluginProcessor.h
[[nodiscard]] Parameters& getParameterRefs() noexcept;
juce::AudioProcessorParameter* getBypassParameter() const noexcept override;
// ...
Parameters parameters{*this}; // поле: сам объект, не ссылка и не указатель
// PluginProcessor.cpp
Parameters& PluginProcessor::getParameterRefs() noexcept {
return parameters;
}
juce::AudioProcessorParameter* PluginProcessor::getBypassParameter() const noexcept {
return ¶meters.bypassed;
}Поле parameters — объект типа Parameters. Ссылкой результат делает тип возвращаемого значения Parameters& в объявлении функции. return parameters; называет сам объект, и возвращаемая ссылка привязывается к нему: вызывающий код получает другое имя поля процессора, копия не создаётся. return ¶meters; здесь не скомпилируется: выражение ¶meters берёт адрес и имеет тип Parameters*, а ссылку к указателю привязать нельзя. Чтобы вернуть адрес, меняют тип результата: getBypassParameter() объявлена с juce::AudioProcessorParameter* и поэтому пишет return ¶meters.bypassed;. Возвращать ссылку можно, пока жив объект, к которому она привязана. Поле живёт вместе с процессором, а редактор удаляется раньше процессора. Ссылку на локальную переменную функции возвращать нельзя: переменная исчезнет при выходе из функции, и ссылка станет висячей.
Копию Parameters вернуть и не удалось бы: структура запрещает копирование и перемещение макросами JUCE_DECLARE_NON_COPYABLE и JUCE_DECLARE_NON_MOVEABLE (см. запрет копирования). По той же причине вызывающий код сохраняет результат в ссылку: auto& params = p.getParameterRefs();. Запись auto params = ... отбрасывает ссылку и пытается скопировать объект, и компилятор сообщит о вызове удалённого конструктора копирования. Редактор обходится без переменной: p.getParameterRefs().rate сразу обращается к полю rate объекта процессора.
Ссылочный параметр устроен так же, только ссылка привязывается при вызове. Например, класс JsonSerializer сохраняет параметры в JSON. Его метод объявлен так: static void serialize(const Parameters&, juce::OutputStream&);. Тип const Parameters& говорит, что функция принимает ссылку на Parameters и не меняет объект через неё. Имя параметра в объявлении можно опустить; в определении в JsonSerializer.cpp он назван parameters.
void PluginProcessor::getStateInformation(juce::MemoryBlock& destData) {
juce::MemoryOutputStream outputStream{destData, true};
JsonSerializer::serialize(parameters, outputStream);
}Аргумент parameters — существующее поле процессора. При вызове ссылка из объявления связывается с этим объектом, и внутри serialize имя параметра обозначает само поле, без копии. Поэтому в месте вызова передают объект: serialize(¶meters, outputStream) не скомпилируется, потому что ¶meters — указатель Parameters*. Второй аргумент передаётся так же: outputStream имеет тип juce::MemoryOutputStream, и ссылка на базовый класс juce::OutputStream& привязывается к нему (см. наследование).
Амперсанд ссылки пишут там, где объявляется тип: у параметра, возвращаемого значения, поля или переменной. В вызове функции и после return стоят выражения, и & в них берёт адрес. Подробнее о двух значениях символа — во врезке ниже.
Указатель T*
Указатель хранит адрес объекта. Он может быть nullptr и может указывать на другой объект после переприсваивания. Доступ к члену через указатель записывают ->, разыменование — *p. Тип const void* — указатель на байты неизвестного типа, которые нельзя менять. Так JUCE передаёт плагину сохранённое состояние: void setStateInformation(const void* data, int sizeInBytes). Объекта «массив с длиной» здесь нет, поэтому размер идёт отдельным параметром.
В аудиокоде указатель обычно показывает на начало блока отсчётов. Метод buffer.getReadPointer(0) возвращает const float* — адрес первого отсчёта канала 0. Следующие отсчёты лежат в памяти подряд, и p[i] читает i-й из них. По типу float* не видно, указывает он на одно число или на массив и сколько в массиве элементов, поэтому длину передают отдельно, как buffer.getNumSamples().
Кто отвечает за удаление объекта, на который указывает указатель, — отдельный вопрос. Он разобран в статье «Кто владеет объектом».
const в типе указателя
В типе указателя участвуют два объекта: сам указатель и значение, на которое он показывает, и каждый из них может быть константным. Правило одно: const левее * относится к значению, правее * — к указателю. Поэтому const float* и float const* — один и тот же тип, порядок const и float до звёздочки не важен. Длинный тип удобно читать справа налево: const float* const p — «p — константный указатель на константный float». Таблица 3 сводит варианты.
| Тип | Как читать | Что нельзя менять | Где встречается |
|---|---|---|---|
float* | указатель на float | всё можно менять | buffer.getWritePointer(0) |
const float* | указатель на константный float | отсчёты | buffer.getReadPointer(0) |
float const* | то же самое: const левее * | отсчёты | та же запись в другом стиле |
float* const | константный указатель на float | сам указатель: его нельзя перенаправить | параметр функции, который тело не должно перенаправлять |
const float* const | константный указатель на константный float | ни отсчёты, ни указатель | то же для блока, который функция только читает |
float* const* | указатель на константный указатель на float | указатели каналов в таблице | buffer.getArrayOfWritePointers() |
const float* const* | указатель на константный указатель на константный float | указатели каналов и отсчёты | buffer.getArrayOfReadPointers() |
Две звёздочки появляются, когда каналов несколько. juce::AudioBuffer хранит таблицу указателей на каналы, а getArrayOfReadPointers() возвращает адрес этой таблицы с типом const float* const*. const перед первой звёздочкой защищает отсчёты, const между звёздочками — указатели каналов в таблице. Сам возвращённый указатель обычный: его можно перенаправить, таблицу и отсчёты это не затронет. getArrayOfWritePointers() возвращает float* const*: отсчёты менять можно, подменить канал нельзя. Те же типы получает колбэк аудиоустройства JUCE audioDeviceIOCallbackWithContext: входные каналы — const float* const*, выходные — float* const*.
const float* const* inputs = buffer.getArrayOfReadPointers();
float* const* outputs = buffer.getArrayOfWritePointers();
const auto x = inputs[1][10]; // канал 1, отсчёт 10: читать можно
outputs[1][10] = 0.5f * x; // писать отсчёт через float* const* можно
// inputs[1][10] = 0.0f; // ошибка: отсчёты константны
// outputs[1] = nullptr; // ошибка: указатели каналов константныУ среднего const есть и второе назначение: float** неявно превращается в const float* const*, но не в const float**. Иначе через const float** можно было бы записать в таблицу адрес константного числа, а потом изменить это число через исходный float*.
Массивы и std::span
Пару «указатель и длина» компилятор не проверяет: длину легко перепутать, а вызов process(nullptr, 512) скомпилируется. Стандартная библиотека хранит длину вместе с данными. std::array<float, N> (C++11) — массив, длина N которого известна при компиляции; элементы лежат внутри самого объекта. В учебном плагине из курса JUCE так объявлены два осциллятора LFO в классе Tremolo: std::array<juce::dsp::Oscillator<float>, 2u> lfos. std::vector<float> — массив, длина которого задаётся при выполнении; память он берёт в куче и освобождает в деструкторе. Поле lfoSamples того же класса — вектор: его размер один раз задаёт resize в методе prepare, который вызывается из prepareToPlay, а process только пишет в готовую память. Метод data() у обоих контейнеров возвращает указатель на первый элемент, так lfoSamples.data() передаётся в функции JUCE, которые принимают указатель и число отсчётов.
Функции не обязательно знать, в каком контейнере лежат отсчёты. std::span<float> (C++20) хранит указатель и длину в одном параметре и не владеет данными. Функция с таким параметром принимает std::array, std::vector и указатель с длиной из AudioBuffer, отсчёты при этом не копируются. std::span<const float> даёт доступ только для чтения, как const float*.
void applyGain (std::span<float> samples, float gain) noexcept
{
for (auto& sample : samples)
sample *= gain;
}
std::array<float, 4> window { 0.0f, 0.5f, 1.0f, 0.5f }; // длина известна при компиляции
std::vector<float> block (512); // длина задаётся при выполнении
applyGain (window, 0.5f);
applyGain (block, 0.5f);
applyGain ({ buffer.getWritePointer (0), // указатель и длина из AudioBuffer
static_cast<std::size_t> (buffer.getNumSamples()) }, 0.5f);
applyGain (std::span { block }.first (64), 0.0f); // первые 64 отсчётаЦикл по диапазону
Цикл for (элемент : коллекция) (C++11) похож на for x in xs в Python и for _, x := range xs в Go. Он работает с любым объектом, у которого есть begin() и end(). Запись const auto x копирует каждый элемент, и для чисел это верно. Для больших объектов пишут const auto& x, чтобы не копировать, и auto& x, чтобы менять элементы на месте.
Вместо классического for (int i = 0; i < n; ++i) код плагина использует std::views::iota (C++20, заголовок <ranges>): ленивую последовательность целых чисел в полуоткрытом интервале [начало, конец), как range(a, b) в Python. Массив в памяти не создаётся, а индекс можно объявить const.
for (const auto frameIndex : std::views::iota(0, buffer.getNumSamples())) {
for (const auto channelIndex : std::views::iota(0, buffer.getNumChannels())) {
const auto inputSample = buffer.getSample(channelIndex, frameIndex);
buffer.setSample(channelIndex, frameIndex, modulationValue * inputSample);
}
}Оба аргумента iota должны быть одного типа. getNumSamples() возвращает int, поэтому начало записано как 0, а не 0u.
noexcept и [[nodiscard]]
noexcept (C++11) обещает, что функция не выбрасывает исключений. Если исключение всё же вылетит, программа завершится через std::terminate. Компилятор может сгенерировать более компактный код, а стандартные контейнеры учитывают noexcept при выборе между копированием и перемещением. В аудиокоде это ещё и документация: методы, которые вызываются из аудиопотока (process, setBypass, reset), не должны бросать исключения, как и выделять память или блокироваться.
void setBypass(bool bypass) noexcept { /* ... */ }
[[nodiscard]] bool isTransitioning() const noexcept { /* ... */ }Атрибут [[nodiscard]] (C++17) просит компилятор предупредить, если результат функции проигнорирован: вызывать isTransitioning(); и не использовать ответ бессмысленно. Двойные квадратные скобки — стандартный синтаксис атрибутов (C++11), отличный от [] индексирования и от захвата лямбды. Атрибут nodiscard — не ключевое слово вроде const, а имя атрибута. Другие атрибуты: [[maybe_unused]], [[fallthrough]], [[likely]]. Правила описаны в стандарте.
Точка входа плагина
У плагина нет функции main(). Вместо неё JUCE ищет функцию с фиксированным именем, которая создаёт процессор:
} // namespace tremolo
// This creates new instances of the plugin.
// This function definition must be in the global namespace.
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() {
return new tremolo::PluginProcessor();
}Функция определена вне namespace tremolo, в глобальном пространстве: внутри пространства её полное имя стало бы tremolo::createPluginFilter, и JUCE её бы не нашёл. Макрос JUCE_CALLTYPE задаёт соглашение о вызове, то есть порядок передачи аргументов. Оно нужно, чтобы плагин и хост одинаково понимали вызов; на Windows это __stdcall, на других платформах макрос пуст.
Выражение new T(...) создаёт объект в куче и возвращает указатель на него. Владение переходит обёртке плагина JUCE, и она позже вызовет delete. Возвращается указатель на базовый класс juce::AudioProcessor*, хотя создан tremolo::PluginProcessor: объект наследника можно использовать везде, где ждут базовый класс (см. наследование). В собственном коде голые new и delete обычно не пишут: для этого есть std::make_unique, подробнее — в «Кто владеет объектом».
Стиль кода в примерах
Компилятор не требует определённых отступов, места скобок или формы имён. Каждый проект выбирает свои правила и закрепляет их форматтером, например clang-format. Учебный плагин tremolo-juce-course отформатирован в стиле Chromium: отступ 2 пробела, открывающая скобка на строке объявления, литерал 0.f. Фрагменты курса приведены на сайте так же, чтобы их было легко найти в репозитории.
Собственные примеры сайта следуют JUCE Coding Standards — правилам оформления самого фреймворка. В них отступ занимает 4 пробела, фигурные скобки стоят на отдельной строке, перед открывающей скобкой вызова с аргументами ставится пробел, литерал пишется как 0.0f, а параметр шаблона называется словом, а не T. Одна и та же функция в двух стилях:
// Оформление учебного плагина (Chromium)
void applyGain(std::span<float> samples, float gain) noexcept {
for (auto& sample : samples)
sample *= gain;
}
// Оформление примеров сайта (JUCE)
void applyGain (std::span<float> samples, float gain) noexcept
{
for (auto& sample : samples)
sample *= gain;
}Примеры собираются в режиме C++23. У величины с единицей измерения единица видна в имени или в комментарии: delaySeconds, latencySamples, double cutoff = 1000.0; // Гц. Объектами владеют поля и std::unique_ptr; голые new и delete встречаются только там, где статья разбирает ручное владение или API JUCE принимает сырой указатель. Вместо массивов C — std::array, std::vector и std::span, вместо приведения (int) x — static_cast<int> (x). Возможности новее C++11 при первом упоминании помечены стандартом в скобках, как std::span (C++20) в этой статье.
Предупреждения компилятора
Файл cmake/CompilerWarnings.cmake включает строгий набор предупреждений, и он объясняет несколько особенностей стиля кода. Таблица 4 связывает флаги с тем, как они видны в коде.
| Флаг | Что проверяет | Как это видно в коде |
|---|---|---|
-Wunused | неиспользуемые переменные и параметры | juce::ignoreUnused(index) |
-Wconversion, -Wsign-conversion, -Wshorten-64-to-32 | неявные преобразования со знаком и сужением | static_cast<size_t>(...), static_cast<int>(...) |
-Woverloaded-virtual | виртуальная функция базы скрыта перегрузкой | using AudioProcessor::processBlock; |
-Wreorder | порядок списка инициализации не совпадает с порядком полей | порядок полей в BypassTransitionSmoother |
-Wshadow | локальное имя скрывает внешнее | параметр конструктора назван crossfadeLengthSecondsValue, а не crossfadeLengthSeconds |
-Wfloat-equal | сравнение вещественных чисел через == | в тестах EXPECT_NEAR и EXPECT_FLOAT_EQ |
-Wzero-as-null-pointer-constant | 0 вместо nullptr | setLookAndFeel(nullptr) |
-Wno-ignored-qualifiers | отключено: бессмысленный const у возвращаемого значения | const juce::String getName() const override |
Предупреждения проверяют не только стиль. Смешение знаковых и беззнаковых чисел, неправильный порядок инициализации и скрытая перегрузка виртуального метода приводят к ошибкам, которые трудно искать при выполнении.
Какой тип у литерала 0.4 без суффикса и почему в коде пишут 0.4f?
Литерал 0.4 имеет тип double. При передаче в параметр типа float Clang с флагом -Wconversion предупредит о сужении double → float. Суффикс f делает литерал float сразу, и преобразования нет.
Что случится, если в цикле for (auto lfo : lfos) вызвать lfo.reset()?
auto отбрасывает ссылку, поэтому lfo — копия элемента массива. reset() сработает на копии, а осцилляторы в lfos не изменятся. Чтобы менять элементы на месте, нужна запись for (auto& lfo : lfos).
Почему getParameterRefs() возвращает Parameters&, хотя в теле написано return parameters, а не return ¶meters?
Ссылкой результат делает тип Parameters& в объявлении, а return parameters; называет объект, к которому ссылка привяжется. ¶meters в выражении берёт адрес и даёт Parameters*; такой return подходит функции, объявленной с типом результата Parameters*, а с Parameters& не скомпилируется.
Функция получает параметр float* const* channels. Какие строки скомпилируются: channels[0][5] = 0.0f; channels[0] = nullptr; channels = nullptr;?
Первая и третья. float в конце цепочки не константен, поэтому отсчёт менять можно. const между звёздочками запрещает подменять указатели каналов в таблице, и вторая строка — ошибка. Сам параметр channels не константен: его можно перенаправить, это изменит только локальную копию указателя в функции.
Зачем в tremolo_plugin.h порядок директив #include важен, а в обычном проекте с отдельными .cpp-файлами это не так?
Плагин собирается как одна единица трансляции: все .cpp вставляются в tremolo_plugin.cpp после общего заголовка. Компилятор читает текст сверху вниз и должен увидеть объявление класса раньше его использования, поэтому Tremolo.h идёт раньше PluginProcessor.h. В проекте, где каждый .cpp подключает нужные заголовки сам, порядок между файлами задаёт не общий заголовок, а директивы каждого файла.
Почему createPluginFilter определена вне namespace tremolo?
JUCE находит функцию по фиксированному имени createPluginFilter в глобальном пространстве. Внутри namespace tremolo её полное имя было бы tremolo::createPluginFilter, и хост-обёртка JUCE её не нашла бы.
Источники
- Alex. Learn C++. Главы 2 «C++ Basics: Functions and Files» (уроки 2.7, 2.8, 2.10, 2.11, 2.12), 4, 5, 7, 10, 11, 12, 16 и 17. в списке литературы → — препроцессор, заголовки, типы, литералы, пространства имён, приведение типов, перегрузка, ссылки, возврат ссылки, указатели и
const,std::vector,std::arrayи циклы. - Wilczek J. C++ Pointers Explained For Audio Programming. Разделы о массивах, двойных указателях,
constиstd::span. в списке литературы → — указатель как адрес блока отсчётов,std::arrayиstd::vectorвместо массивов C, таблица каналовfloat**в колбэке аудиоустройства, чтениеconstсправа налево,std::spanвместо пары «указатель и длина». - cppreference. Pointer declaration — раздел Constness:
constдо и после*, равенствоconst T*иT const*; std::span — невладеющий вид на непрерывную память. - cppreference. static_cast — допустимые преобразования; std::size_t — определение и заголовок
<cstddef>; noexcept specifier и nodiscard — семантика и стандарт появления. - Проект стандарта C++ (eel.is/c++draft): support.types.layout —
size_t, namespace.unnamed — анонимное пространство имён, dcl.attr.grammar — синтаксис атрибутов. - JUCE. JUCE Module Format — модуль из одного
.cpp, который вставляет остальные исходники, и блокBEGIN_JUCE_MODULE_DECLARATION. - CMake. UNITY_BUILD и target_precompile_headers — автоматический unity build и предкомпилированные заголовки (CMake 3.16).
- cppreference. Modules — модули C++20 и отличие
importот#include. - Doxygen. Documenting the code — формы комментариев документации.
- JUCE. Coding Standards — оформление и соглашения исходников JUCE, на которых основан стиль примеров сайта.
- Clang. ClangFormatStyleOptions — параметр
BasedOnStyleи встроенные стили, в том числе Chromium. - JUCE. tremolo-juce-course — фрагменты кода из папки
complete:tremolo_plugin.h,tremolo_plugin.cpp,PluginProcessor.h,PluginProcessor.cpp,Parameters.cpp,Tremolo.h,BypassTransitionSmoother.h,cmake/CompilerWarnings.cmake,CMakeLists.txt,test/source/TestUtils.h,Parameters.h,JsonSerializer.hиJsonSerializer.cpp. - JUCE 8.0.12: juce_AudioSampleBuffer.h — типы
getReadPointer,getWritePointer,getArrayOfReadPointersиgetArrayOfWritePointers; juce_AudioIODevice.h — параметрыaudioDeviceIOCallbackWithContext.
Связи
Связи списком
Ссылается на
- Аудиоданные в программах: представление отсчётов, каналы, кадры и буферы
- Кто владеет объектом: std::make_unique и время жизни в плагине JUCE
- Стандартная библиотека C++ в плагине: optional, контейнеры, лямбды и std::function
- C++ в аудиопотоке: выделение памяти, общие объекты и инструменты JUCE
- C++ в плагине: классы, конструкторы и время жизни
- C++ в плагине: шаблоны и сериализация параметров
Ссылаются сюда
- Аудиоданные в программах: представление отсчётов, каналы, кадры и буферы
- Голос синтезатора: сборка и рендер ноты в WAV
- Кто владеет объектом: std::make_unique и время жизни в плагине JUCE
- Микширование сигналов: усиление, фаза и запас уровня
- Спектр сигнала: ДПФ и БПФ
- Стандартная библиотека C++ в плагине: optional, контейнеры, лямбды и std::function
- C++ в аудиопотоке: выделение памяти, общие объекты и инструменты JUCE
- C++ в плагине: классы, конструкторы и время жизни
- C++ в плагине: шаблоны и сериализация параметров