Три предыдущие статьи серии построили части одноголосного синтезатора по отдельности: источники и микшер, огибающую ADSR с VCA и фильтр нижних частот. Здесь они собираются в голос синтезатора (synth voice)голос синтезатора (synth voice) — Набор генераторов и обработки, который звучит одной нотой: источники, микшер, фильтр, усилитель и огибающая. Одноголосный синтезатор играет одну ноту одновременно. — объект, который по событиям ноты выдаёт отсчёты (sample)отсчёт (sample) — Значение сигнала в один момент дискретизации, одно число. В звукозаписи его также называют сэмплом.. Голос рендерит одну ноту в буфер JUCE, консольная программа записывает буфер в WAV (WAVE file)WAV-файл (WAVE file) — Звуковой файл со структурой RIFF и типом формы WAVE. Чанк fmt описывает параметры звука, чанк data содержит отсчёты; обычно это несжатый PCM., а проверки подтверждают, что файл получился ожидаемым. Код на C++23 и JUCE 9.0.3 разбит на блоки по разделам; вместе с кодом предыдущих статей они собираются в работающий проект.
Цепочка голоса
Голос вычисляет отсчёт за отсчётом, и для каждого порядок одинаков:
- Три осциллятора (oscillator)осциллятор (oscillator) — Генератор повторяющегося колебания. Частота задаёт число повторений за секунду, а форма волны — изменение значения внутри одного периода. и генератор шума выдают по значению в пределах ±1 и продвигают своё состояние.
- Микшер умножает каждое значение на уровень источника и складывает: .
- Однополюсный фильтр (one-pole filter)однополюсный фильтр (one-pole filter) — Простейший рекурсивный фильтр: новый выход вычисляется из входного отсчёта и одного предыдущего выхода, y[n] = y[n−1] + a·(x[n] − y[n−1]). При коэффициенте a от 0 до 1 работает как фильтр нижних частот. превращает в , используя своё прошлое значение .
- VCA (voltage-controlled amplifier)VCA (voltage-controlled amplifier) — Усилитель голоса синтезатора, коэффициент которого задаёт управляющий сигнал. В цифровом голосе — умножение каждого отсчёта на значение огибающей. умножает на очередное значение огибающей .
Порядок фильтра и VCA важен. Фильтр стоит до умножения, поэтому его состояние не зависит от громкости ноты, а затихание задаёт только огибающая. Если поставить VCA первым, фильтр будет сглаживать уже затихающий сигнал и продолжит выдавать остаток после того, как огибающая дошла до нуля: конец ноты станет зависеть от частоты среза. В цепочке выше последний отсчёт release умножается на 0 и равен нулю точно.
Параметры отдельно от состояния
Каждый класс серии делит свои данные на две группы. Параметры задаёт пользователь: форма волны, уровни, частота среза, времена огибающей. Состояние меняется на каждом отсчёте: фаза осциллятора, фильтра, стадия и уровень огибающей, внутренние числа генератора шума. Для голоса это деление становится явным: все параметры собраны в одну структуру VoiceParameters, а состояние остаётся внутри компонентов.
| Компонент | Параметры | Состояние |
|---|---|---|
| Осцилляторы 1, 2 и саб | форма, уровень, интервал и расстройка второго | фаза в долях периода |
| Шум | цвет, уровень, начальное число генератора | состояние PRNG, ряды розового шума |
| Фильтр | частота среза | y[n−1] |
| Огибающая | A, D, S, R, форма участков | стадия, уровень, шаг, счётчик |
Так рендер становится повторяемым: одинаковые параметры и одинаковое начальное состояние дают одинаковые отсчёты, а значит и одинаковый файл. reset() знает, что возвращать в начало, и не трогает настройки. А параметры можно отдать нескольким голосам сразу: полифонический синтезатор держит одну структуру настроек и много голосов, каждый со своим состоянием.
// SynthVoice.h: параметры голоса — значения, которые задаёт пользователь, без состояния.
#pragma once
#include "AdsrEnvelope.h"
#include "LowPassFilter.h"
#include "Noise.h"
#include "Oscillator.h"
enum class NoiseColour { white, pink };
struct VoiceParameters
{
Waveform shape1 = Waveform::saw, shape2 = Waveform::saw;
Waveform subShape = Waveform::square; // синус или прямоугольник
double level1 = 0.35, level2 = 0.35, subLevel = 0.2, noiseLevel = 0.05;
int interval = 0; // второй осциллятор, полутоны
double detune = 7.0; // второй осциллятор, центы
NoiseColour noiseColour = NoiseColour::white;
double cutoff = 2000.0; // частота среза, Гц
AdsrEnvelope::Parameters envelope; // A, D, S, R и форма участков
};Уровни те же, что в статье об осцилляторах, плюс немного белого шума. Запас уровня (headroom)запас уровня (headroom) — Расстояние от текущего или ожидаемого пикового уровня до предела выбранного участка аудиотракта. Обычно выражается в децибелах. оценивается так же: каждый источник не выходит за ±1, поэтому пик суммы не больше , около −0,45 dBFS (decibels relative to full scale)dBFS (decibels relative to full scale) — Уровень цифрового сигнала в децибелах относительно полной шкалы. Для пиковых значений 0 dBFS соответствует её границе; формат с плавающей точкой допускает значения выше неё. Соглашение об опоре RMS указывают отдельно.. Фильтр с не выводит сигнал за пределы входа, а огибающая не больше 1, поэтому оценка остаётся верной и на выходе голоса. AdsrEnvelope::Parameters по умолчанию: A = 10 мс, D = 200 мс, S = 0,6, R = 300 мс, линейные участки.
Общий интерфейс компонентов
Компоненты серии устроены одинаково: prepare (sampleRate) сообщает частоту дискретизации (sample rate)частота дискретизации (sample rate) — Число отсчётов в каждом канале за одну секунду, то же число кадров аудиоданных в секунду. Измеряется в герцах: 44 100 Гц означает 44 100 отсчётов на канал в секунду. и пересчитывает то, что от неё зависит; reset() возвращает состояние в начало; третий метод выдаёт следующий отсчёт. Различия продиктованы ролью компонента.
| Тип | Подготовка | Сброс | Следующий отсчёт |
|---|---|---|---|
| Oscillator | prepare (fs) | reset (startPhase) | nextSample() |
| NoiseGenerator | — от fs не зависит | reset() | nextSample() |
| LowPassFilter | prepare (fs) | reset (value) | processSample (x) |
| AdsrEnvelope | prepare (fs) | reset() | nextSample(); события noteOn(), noteOff() |
| SynthVoice | prepare (fs) | reset() | nextSample(); события noteOn (f), noteOff() |
Осциллятор и огибающая — генераторы: следующий отсчёт зависит только от их состояния. Фильтр — преобразователь: ему нужен входной отсчёт, поэтому метод называется processSample и принимает . Шуму частота дискретизации не нужна: PRNG (pseudorandom number generator, PRNG)генератор псевдослучайных чисел (pseudorandom number generator, PRNG) — Детерминированный алгоритм, который по текущему состоянию выдаёт следующее число и обновляет состояние. Одинаковое начало и порядок вызовов воспроизводят последовательность. выдаёт следующее число независимо от того, как часто его вызывают (подробнее в статье о шуме).
Генераторы белого и розового шума из статьи о шуме уже есть; не хватает переключения цвета. NoiseGenerator хранит оба генератора и начальное число (seed)начальное состояние генератора (seed) — Значение, задающее начало псевдослучайной последовательности. Воспроизводимость требует той же реализации и того же порядка вызовов., чтобы reset() повторял последовательность с начала.
// SynthVoice.h: шум голоса — цвет как параметр, PRNG и ряды розового шума как состояние.
class NoiseGenerator
{
public:
explicit NoiseGenerator (juce::int64 seedToUse = 2026) noexcept
: seed (seedToUse), white (seedToUse), pink (seedToUse) {}
void reset() noexcept
{
white.reset (seed);
pink.reset (seed);
}
void setColour (NoiseColour value) noexcept { colour = value; }
double nextSample() noexcept
{
return colour == NoiseColour::white ? white.nextSample() : pink.nextSample();
}
private:
juce::int64 seed;
WhiteNoise white;
PinkNoise pink;
NoiseColour colour = NoiseColour::white;
};Оба генератора получают начальное число из аргумента конструктора, а не из члена seed. Члены класса инициализируются в порядке объявления, а не в порядке записи в списке инициализации, поэтому значение одного члена лучше не выводить из другого: при перестановке объявлений white получил бы ещё не заданный seed.
SynthVoice
SynthVoice владеет компонентами и вызывает их в порядке цепочки. Снаружи голосом управляют noteOn (frequency), noteOff(), nextSample() и isActive().
// SynthVoice.h: голос — источники, микшер, фильтр и VCA с огибающей.
#include <initializer_list>
class SynthVoice
{
public:
void prepare (double sampleRate) noexcept
{
for (auto* osc : { &osc1, &osc2, &sub })
osc->prepare (sampleRate);
filter.prepare (sampleRate);
envelope.prepare (sampleRate);
reset();
}
// Всё состояние — в начальное: фазы, PRNG шума, y[n-1] фильтра, стадия огибающей.
void reset() noexcept
{
for (auto* osc : { &osc1, &osc2, &sub })
osc->reset();
noise.reset();
filter.reset();
envelope.reset();
}
// Вызывать между нотами: частоту осцилляторов задаёт noteOn.
void setParameters (const VoiceParameters& newParameters) noexcept
{
params = newParameters;
osc1.setWaveform (params.shape1);
osc2.setWaveform (params.shape2);
sub.setWaveform (params.subShape);
noise.setColour (params.noiseColour);
filter.setCutoff (params.cutoff);
envelope.setParameters (params.envelope);
}
void noteOn (double frequency) noexcept
{
osc1.setFrequency (frequency);
const double cents = 100.0 * params.interval + params.detune;
osc2.setFrequency (frequency * std::exp2 (cents / 1200.0));
sub.setFrequency (frequency / 2.0);
if (! envelope.isActive()) // голос молчал: начать с чистого состояния
{
for (auto* osc : { &osc1, &osc2, &sub })
osc->reset();
filter.reset();
}
envelope.noteOn();
}
void noteOff() noexcept { envelope.noteOff(); }
bool isActive() const noexcept { return envelope.isActive(); }
double nextSample() noexcept
{
const double mixed = params.level1 * osc1.nextSample()
+ params.level2 * osc2.nextSample()
+ params.subLevel * sub.nextSample()
+ params.noiseLevel * noise.nextSample();
return filter.processSample (mixed) * envelope.nextSample(); // фильтр, затем VCA
}
private:
VoiceParameters params;
Oscillator osc1, osc2, sub;
NoiseGenerator noise;
LowPassFilter filter;
AdsrEnvelope envelope;
};noteOn всегда задаёт частоты: второй осциллятор — через интервал и расстройку (detune)расстройка (detune) — Небольшое отклонение частоты одного генератора от другого или от точного интервала, обычно в центах. При одновременном звучании даёт биения. по формуле из статьи об осцилляторах, саб-осциллятор (sub-oscillator)саб-осциллятор (sub-oscillator) — Дополнительный генератор голоса, звучащий на октаву или две ниже основной ноты. Обычно даёт синус или прямоугольник. — на октаву ниже. Дальше всё зависит от того, звучит ли голос. Если огибающая в покое, фазы осцилляторов и состояние фильтра сбрасываются: нота начинается с одинакового сочетания фаз и без остатка прошлой. Если голос ещё в release, сброс дал бы скачок сигнала на входе VCA и щелчок, поэтому фазы и продолжаются. Огибающая в обоих случаях начинает атаку с текущего уровня.
Шум в noteOn не сбрасывается: две ноты подряд получают разные отрезки шума, как у генератора, который работает непрерывно. Начальное состояние генератора восстанавливает только reset() — его вызывают перед рендером, чтобы файл повторялся. setParameters по той же причине, что у огибающей, вызывают между нотами: новые времена ADSR применяются со следующей стадии, а новая частота среза меняет тембр сразу.
isActive() повторяет огибающую: голос нужен, пока она не вернулась в покой. Для одноголосного рендера это признак конца ноты, для полифонического синтезатора — признак, что голос можно отдать следующей ноте.
Рендер ноты
Рендер переводит длительность нажатия в номер отсчёта note_off и заполняет буфер, пока огибающая не дойдёт до нуля. Верхний предел длины известен заранее: release всегда длится R от текущего уровня, поэтому после note_off нужно не больше отсчётов (как огибающая считает стадии).
- верхний предел длины рендера, отсчётов;
- длительность нажатия, от
note_onдоnote_off, с; - время release, с.
Для ноты 0,8 с и R = 300 мс при 48 кГц: отсчётов, 1,1 с. Каждое слагаемое округляется отдельно, как в коде, — так номер note_off совпадает с тем, что огибающая отсчитает сама.
// Main.cpp: рендер ноты в буфер — note_off на заданном отсчёте, конец по isActive().
#include "SynthVoice.h"
#include <juce_audio_basics/juce_audio_basics.h>
juce::AudioBuffer<float> renderNote (SynthVoice& voice, const VoiceParameters& parameters,
double frequency, double noteSeconds, double sampleRate)
{
const double releaseSeconds = parameters.envelope.release;
const auto noteOffAt = static_cast<int> (std::llround (noteSeconds * sampleRate));
const auto maxLength = noteOffAt + static_cast<int> (std::llround (releaseSeconds * sampleRate));
juce::AudioBuffer<float> buffer (1, maxLength); // один канал: голос моно
voice.setParameters (parameters);
voice.reset();
voice.noteOn (frequency);
int length = 0;
while (length < maxLength)
{
if (length == noteOffAt)
voice.noteOff();
buffer.setSample (0, length++, static_cast<float> (voice.nextSample()));
if (! voice.isActive()) // огибающая дошла до нуля: дальше только тишина
break;
}
buffer.setSize (1, length, true); // при S = 0 нота кончается раньше предела
return buffer;
}juce::AudioBuffer<float> создаётся с одним каналом: голос монофонический. Конструктор не обнуляет память (AudioBuffer), но цикл записывает каждый отсчёт от 0 до length − 1, а setSize (1, length, true) отрезает хвост, сохраняя содержимое. Отсчёт голоса вычисляется в double и округляется до float один раз, при записи в буфер.
Цикл останавливается двумя способами. Обычно огибающая проходит release и переходит в покой ровно на последнем отсчёте предела: длина равна , последний отсчёт — 0. При S = 0 огибающая уходит в покой сразу после спада, ещё до note_off, и рендер заканчивается раньше: перкуссионная нота с A = 5 мс и D = 180 мс занимает 8880 отсчётов вместо 28 800. Предел нужен на случай, когда огибающая не затихает сама, например при R = 0: тогда буфер обрывается на note_off и последний отсчёт не равен нулю.
WAV-слой
Запись в файл отделена от звука: функция получает готовый буфер и не знает ни о голосе, ни о нотах. Поэтому её можно проверить отдельно, а голос — использовать там, где файла нет, например в аудиоблоке плагина.
// Main.cpp: запись готовых отсчётов в WAV; аудиологики здесь нет.
#include <juce_audio_formats/juce_audio_formats.h>
bool writeWav (const juce::AudioBuffer<float>& buffer, double sampleRate, int bits,
const juce::File& file)
{
file.deleteFile(); // FileOutputStream дописывает в конец существующего файла
std::unique_ptr<juce::OutputStream> stream = file.createOutputStream();
if (stream == nullptr)
return false;
juce::WavAudioFormat wav;
const auto options = juce::AudioFormatWriterOptions{}
.withSampleRate (sampleRate)
.withNumChannels (buffer.getNumChannels())
.withBitsPerSample (bits); // 16 или 24
auto writer = wav.createWriterFor (stream, options);
if (writer == nullptr)
return false;
return writer->writeFromAudioSampleBuffer (buffer, 0, buffer.getNumSamples());
} // деструктор writer записывает размеры чанков и закрывает файлВ JUCE 9 createWriterFor принимает поток по ссылке на std::unique_ptr и параметры в AudioFormatWriterOptions; прежний вариант с сырым указателем помечен устаревшим. Если писатель создан, он забирает поток себе и обнуляет stream; если нет, поток остаётся у вызывающего кода (juce_AudioFormat.h 9.0.3). File::createOutputStream открывает файл на запись с позиции в конце, поэтому старый файл сначала удаляется: иначе новая запись окажется после прежних байтов.
Размеры чанков RIFF и data в начале файла зависят от числа записанных отсчётов, поэтому JUCE дописывает заголовок в деструкторе писателя (juce_WavAudioFormat.cpp 9.0.3). Файл готов, когда writer вышел из области видимости. Между fmt и RIFF JUCE вставляет чанк JUNK на 52 байта: если запись превысит 4 ГБ, на его месте окажется ds64 формата RF64. Поэтому заголовок занимает 104 байта, а не 44, и нота из 52 800 отсчётов в 24 битах весит байта.
Разрядность и перевод в целые
withBitsPerSample выбирает размер целого PCM (pulse-code modulation, PCM)импульсно-кодовая модуляция (pulse-code modulation, PCM) — Представление сигнала последовательностью квантованных отсчётов, взятых через равные промежутки времени. В WAV код формата 1 означает PCM с целыми отсчётами.-отсчёта. 16 бит хватает, чтобы послушать ноту, 24 бита оставляют больше точности, если файл пойдёт в дальнейшую обработку. Перевод float в целые JUCE делает в два шага (juce_AudioFormatWriter.cpp, juce_AudioDataConverters.h). Сначала отсчёт становится 32-битным целым: значения от −1 и ниже — минимум, от 1 и выше — максимум, остальные — roundToInt (2147483647 · x), где половины округляются к чётному. Затем лишние младшие разряды отбрасываются сдвигом вправо: на 16 для 16 бит, на 8 для 24 бит.
Сдвиг вправо у отрицательного числа округляет вниз, к −∞, а не к ближайшему. Поэтому JUCE иногда записывает код на единицу меньше, чем кодер из статьи о PCM: например, при 16 битах даёт у JUCE −1, а округление к ближайшему — 0. Разница не превышает одного шага квантования (quantization)квантование (quantization) — Замена значения отсчёта одним из конечного набора представимых уровней. Разность между выбранным уровнем и исходным значением называется ошибкой квантования., а края шкалы совпадают: даёт 32 767, — −32 768. Dither писатель не добавляет. Перевод ограничивает только целую шкалу: всё, что выше 0 dBFS, будет срезано, и запас уровня нужно обеспечить до записи.
Консольное приложение
Проект состоит из семи файлов. Четыре заголовка — код предыдущих статей без изменений; в начало каждого добавлена строка #pragma once, чтобы повторное включение не дублировало определения.
synth-note/
├── CMakeLists.txt
├── Main.cpp — разделы «Рендер ноты», «WAV-слой» и «Проверка результата»
├── SynthVoice.h — разделы «Параметры», «Общий интерфейс» и «SynthVoice»
├── Oscillator.h — /synth-oscillators/: shapeValue, polyBlep и класс Oscillator
├── Noise.h — /noise/: классы WhiteNoise и PinkNoise
├── AdsrEnvelope.h — /adsr/: класс AdsrEnvelope и метод start
└── LowPassFilter.h — /one-pole-filter/: класс LowPassFilterMain.cpp — единственная единица трансляции, поэтому функции из заголовков (polyBlep, shapeValue, AdsrEnvelope::start) можно оставить без inline. Если заголовки включаются в несколько .cpp, такие определения нужно пометить inline, иначе компоновщик найдёт их дважды.
CMakeLists.txt скачивает JUCE модулем FetchContent и объявляет консольное приложение. FetchContent_MakeAvailable загружает исходники при настройке проекта и подключает их через add_subdirectory, как будто JUCE лежит рядом (CMake, FetchContent). juce_add_console_app создаёт исполняемый файл, к которому подключаются только нужные модули: juce_audio_basics для AudioBuffer и MidiMessage, juce_audio_formats для WAV (JUCE, CMake API).
cmake_minimum_required (VERSION 3.22)
project (SynthNote VERSION 0.1.0)
include (FetchContent)
FetchContent_Declare (JUCE
GIT_REPOSITORY https://github.com/juce-framework/JUCE.git
GIT_TAG 9.0.3
GIT_SHALLOW TRUE)
FetchContent_MakeAvailable (JUCE) # скачивает JUCE при первой настройке
juce_add_console_app (SynthNote PRODUCT_NAME "Synth Note")
target_sources (SynthNote PRIVATE Main.cpp)
target_compile_features (SynthNote PRIVATE cxx_std_20)
target_compile_definitions (SynthNote PRIVATE JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0)
target_link_libraries (SynthNote
PRIVATE juce::juce_audio_basics juce::juce_audio_formats
PUBLIC juce::juce_recommended_config_flags)JUCE_WEB_BROWSER=0 и JUCE_USE_CURL=0 отключают веб-модули, которые консольной программе не нужны; так же сделано в примере ConsoleApp из JUCE. Цель juce_recommended_config_flags задаёт флаги оптимизации и отладки. Строгие предупреждения JUCE (juce_recommended_warning_flags) не подключены: с ними компилятор жалуется на код предыдущих статей, например на сравнение double через == в AdsrEnvelope.
Сборка и запуск из папки проекта:
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
./build/SynthNote_artefacts/Release/SynthNoteПервая настройка скачивает JUCE и собирает его вспомогательную программу juceaide, поэтому занимает заметно дольше следующих. JUCE кладёт программу в папку SynthNote_artefacts с подпапкой конфигурации; путь выше проверен на macOS с генератором CMake по умолчанию.
Проверка результата
Файл легко записать неправильно так, что он всё равно откроется: со срезанными вершинами, щелчком на конце или не той длины. Проверки ниже ловят эти ошибки до прослушивания.
- Пик ниже 0 dBFS:
getMagnitudeнаходит наибольший модуль отсчёта. Пик 1 и выше при переводе в целые срезается. - Последний отсчёт около нуля: ненулевой последний отсчёт — разрыв на границе файла: проигрыватель после него выдаёт тишину, и на конце слышен щелчок.
- Длина совпадает с расчётной: файл читается обратно через AudioFormatManager, и
lengthInSamplesсравнивается с формулой (1). Так проверяется и рендер, и заголовок, который писатель дописал при закрытии. - Результат одинаков при одинаковом начальном числе: два рендера подряд с вызовом
reset()должны совпасть до бита.
// Main.cpp: проверки буфера и файла после записи.
#include <cstdio>
bool checkRender (const juce::AudioBuffer<float>& buffer, const juce::File& file,
int expectedLength)
{
const int length = buffer.getNumSamples();
const float peak = buffer.getMagnitude (0, 0, length);
const float last = buffer.getSample (0, length - 1);
std::printf ("peak %.4f (%.2f dBFS), last %.2e, length %d of %d\n", peak,
juce::Decibels::gainToDecibels (peak, -200.0f), last, length, expectedLength);
juce::AudioFormatManager formats;
formats.registerBasicFormats();
std::unique_ptr<juce::AudioFormatReader> reader (formats.createReaderFor (file));
return reader != nullptr && reader->lengthInSamples == length
&& peak < 1.0f && std::abs (last) < 1.0e-4f && length == expectedLength;
}main собирает всё вместе: готовит голос, рендерит ноту A3 дважды, записывает первый рендер в note.wav в текущей папке и сравнивает рендеры между собой. Частоту ноты даёт juce::MidiMessage::getMidiNoteInHertz — та же формула, что в статье об осцилляторах.
// Main.cpp: нота A3 длительностью 0,8 с при 48 кГц; два рендера должны совпасть.
int main()
{
constexpr double sampleRate = 48000.0, noteSeconds = 0.8;
const VoiceParameters params; // значения по умолчанию из VoiceParameters
const double frequency = juce::MidiMessage::getMidiNoteInHertz (57); // 220 Гц
SynthVoice voice;
voice.prepare (sampleRate);
const auto first = renderNote (voice, params, frequency, noteSeconds, sampleRate);
const auto second = renderNote (voice, params, frequency, noteSeconds, sampleRate);
const bool same = first.getNumSamples() == second.getNumSamples()
&& std::equal (first.getReadPointer (0), first.getReadPointer (0) + first.getNumSamples(),
second.getReadPointer (0));
const auto file = juce::File::getCurrentWorkingDirectory().getChildFile ("note.wav");
const double totalSeconds = noteSeconds + params.envelope.release;
const auto expected = static_cast<int> (std::llround (totalSeconds * sampleRate));
const bool ok = writeWav (first, sampleRate, 24, file) && checkRender (first, file, expected);
std::printf ("%s, repeat %s\n", ok ? "ok" : "FAILED", same ? "identical" : "DIFFERENT");
return ok && same ? 0 : 1;
}Программа собрана Apple Clang 17 на macOS (arm64) с JUCE 9.0.3, загруженным через FetchContent; код этой статьи компилируется с -Wall -Wextra -Wconversion без предупреждений. Вывод программы:
peak 0.8681 (-1.23 dBFS), last 0.00e+00, length 52800 of 52800
ok, repeat identicalПик 0,868 ниже худшей оценки 0,95. Он приходится на 13,5 мс, сразу после вершины атаки, когда огибающая ещё около 1; в sustain она держит 0,6, и пик там 0,444. В первые миллисекунды пики источников совпадают не полностью: даже с частотой среза 16 кГц пик ноты 0,909. Последний отсчёт равен нулю точно — огибающая на последнем отсчёте release присваивает уровню цель 0. Повтор совпадает, потому что reset() возвращает генератор шума к начальному числу 2026. Повторный запуск программы даёт тот же файл байт в байт; на другом компиляторе или процессоре младшие разряды std::sin и std::pow могут отличаться, поэтому сравнивать файлы между машинами имеет смысл с допуском.
Послушать и скачать
Визуализация считает голос тем же кодом, переписанным на JavaScript, при 48 кГц. Сверху — форма всей ноты: размах повторяет огибающую, пунктир отмечает пик. Рамка показывает, какой участок увеличен снизу; кнопки «Атака», «Sustain» и «Release» выбирают его начало. Звук и файл WAV строятся из тех же отсчётов. Файл повторяет запись JUCE, включая чанк JUNK и правило округления, поэтому при параметрах по умолчанию он совпадает с note.wav из программы байт в байт; так же совпали 16- и 24-битные файлы трёх примеров.
- отсчёты голоса
- пик ±
- увеличенный фрагмент
- note_off
- смена стадии
Длина 52 800 отсчётов (1,10 с) = note_off на отсчёте 38 400 + release 14 400 отсчётов. Последний отсчёт 0,000.
Пик 0,868 (−1,23 dBFS); худшая оценка микшера Σ|g| = 0,95 (−0,45 dBFS).
Фрагмент «Атака»: отсчёты 0–872, 18 мс.
Пример «Перкуссия» использует S = 0: нота кончается после спада, раньше note_off, и статус показывает, сколько отсчётов занял рендер. «Бас» берёт низкую ноту, частоту среза около 600 Гц и короткий release, «Пэд» — медленные атаку и release, квинту с расстройкой и розовый шум. Поставьте R = 0: статус сообщит, что последний отсчёт не равен нулю, а в скачанном файле на конце будет щелчок.
Что дальше
Голос из этой статьи переносится в плагин почти без изменений. В JUCE для этого есть juce::Synthesiser и juce::SynthesiserVoice (juce_Synthesiser.h 9.0.3). Наследник SynthesiserVoice получает startNote с номером ноты и stopNote с флагом allowTailOff — это noteOn и noteOff. В renderNextBlock он прибавляет свои отсчёты к буферу, а не перезаписывает его: в тот же буфер пишут и другие голоса. Когда isActive() вернул false, голос вызывает clearCurrentNote(), и синтезатор может отдать его новой ноте.
juce::Synthesiser делит аудиоблок на части между событиями MIDI — по умолчанию не короче 32 отсчётов, кроме первой части блока, — и сам распределяет ноты по голосам. Несколько объектов голоса дают полифонию (polyphony): каждый хранит своё состояние, а параметры у всех общие, ради этого их и вынесли в VoiceParameters. Когда свободных голосов нет, синтезатор может забрать звучащий: сначала самую старую ноту той же высоты, затем самую старую отпущенную, при этом старается не трогать самую низкую и самую высокую ноту (juce_Synthesiser.cpp). Забранный голос надо быстро заглушить, иначе обрыв даст щелчок.
renderNote выделяет буфер под всю ноту, а writeWav пишет на диск. Для консольной программы это нормально, но в аудиопотоке (audio thread)аудиопоток (audio thread) — Поток с повышенным приоритетом, который вызывает обработку звука для каждого блока. Каждый вызов должен закончиться до срока блока, поэтому в нём не выделяют память, не берут блокировки и не обращаются к диску. плагина так нельзя: выделение памяти и обращение к диску могут занять непредсказуемое время, и блок не успеет к сроку (Bencina, Real-time audio programming 101). Голос же ничего не выделяет и годится для аудиопотока как есть: его состояние создаётся вместе с объектом, а nextSample только вычисляет.
Звучание голоса можно развивать в нескольких направлениях:
- Огибающая фильтра: вторая
AdsrEnvelope, которая на каждом отсчёте меняет частоту среза, — классический «вау» в начале ноты. Коэффициент тогда пересчитывается часто, иstd::expизsetCutoffстоит вынести или заменить таблицей. - Резонанс: однополюсному фильтру его взять неоткуда, нужен фильтр второго порядка или петля обратной связи.
- Модуляция: LFO на высоту даёт вибрато, на уровень — тремоло, на частоту среза — медленное движение тембра.
Самопроверка
Нота длится 0,5 с, R = 250 мс, частота дискретизации 44,1 кГц. Каков верхний предел длины рендера и сколько байт займёт 16-битный моно-WAV, записанный JUCE?
отсчётов. Данные займут байт, вместе с заголовком JUCE на 104 байта — 66 254 байта.
Почему noteOn сбрасывает фазы осцилляторов и состояние фильтра только у молчащего голоса?
У молчащего голоса огибающая равна нулю, и сброс не слышен, зато каждая нота начинается одинаково. Если голос ещё звучит в release, скачок фазы или изменит сигнал на входе VCA при ненулевом усилении — получится щелчок. Поэтому у звучащего голоса меняется только частота.
Рендер с S = 0 оказался короче формулы (1). Это ошибка?
Нет. При S = 0 огибающая после спада переходит в покой, isActive() возвращает false, и цикл останавливается до note_off. Формула (1) даёт верхний предел длины; совпадать с ней должна нота, огибающая которой дошла до note_off.
Значение −0,00001 записывается в 16-битный WAV. Какой код запишет JUCE и какой — округление к ближайшему?
JUCE: , после roundToInt −21 475; сдвиг вправо на 16 разрядов округляет вниз и даёт −1. Округление к ближайшему: , то есть 0. Разница — один шаг квантования.
Источники
- JUCE 9.0.3. juce_WavAudioFormat.cpp, juce_AudioFormat.h, juce_AudioFormatWriterOptions.h, juce_AudioFormatWriter.cpp и juce_AudioDataConverters.h — создание писателя с
AudioFormatWriterOptionsи передача потока, заголовок с чанком JUNK, запись заголовка в деструкторе, перевод float в int32 черезroundToIntи сдвиг до 16 и 24 бит. - JUCE. AudioBuffer — конструктор без обнуления,
setSample,setSize,getMagnitude. - JUCE 9.0.3. juce_Synthesiser.h и juce_Synthesiser.cpp — контракт
SynthesiserVoice(прибавление к буферу,stopNoteсallowTailOff,clearCurrentNote), деление блока между событиями MIDI с минимумом 32 отсчёта, выбор голоса для забора. - JUCE 9.0.3. CMake API и пример
examples/CMake/ConsoleApp—juce_add_console_app,juce_recommended_config_flagsиjuce_recommended_warning_flags, отключение веб-модулей. - CMake. FetchContent — загрузка при настройке проекта и подключение через
add_subdirectoryвFetchContent_MakeAvailable. - Bencina R. Real-time audio programming 101: time waits for nothing. в списке литературы → — почему в аудиопотоке не выделяют память и не обращаются к диску.
Связи
Связи списком
Ссылается на
- Аудиоданные в программах: представление отсчётов, каналы, кадры и буферы
- Белый и розовый шум: спектр и генерация
- Дизеринг и формирование шума: как понизить разрядность без искажений
- Кто владеет объектом: std::make_unique и время жизни в плагине JUCE
- Микширование сигналов: усиление, фаза и запас уровня
- Огибающая ADSR и VCA: как нота начинается и затихает
- Однополюсный фильтр нижних частот
- Основы C++ для JUCE: сборка, типы, функции и указатели
- Осцилляторы синтезатора: формы, расстройка и саб-осциллятор
- Рост разрядности: от 16 бит CD к 24 битам и float в DAW
- Тремоло: глубина модуляции и усиление
- Устройство WAV-файла: RIFF, чанки, fmt и data
- Целочисленный PCM: байты, знак и перевод во float
- C++ в аудиопотоке: выделение памяти, общие объекты и инструменты JUCE
- LFO: низкочастотный генератор и модуляция