Embedded HandbookПриложения
Все разделы › III. Периферия
STM32F405, STM32F373, STM32F103 · уровень продвинутый · 27 мин чтения
Открыть в интерактивном справочнике

18в. Синтезатор на STM32: разбор модулей Mutable Instruments — кодек, блок сэмплов и DSP в реальном времени

В разделах 18а и 18б звук шёл через I2S и SAI: кадр, частота отсчётов, двойной буфер DMA. В мини-проекте 198п был свой простой синтезатор на ЦАП. Здесь — как те же приёмы собраны в настоящих приборах: модулях формата Eurorack фирмы Mutable Instruments. Их исходники и схемы открыты, модули выпускались серийно, и в коде видно всё: какой кодек, какой блок отсчётов, как напряжение управления превращается в ноту и как генератор обходится без наложения спектра.

Разбор идёт по пути сигнала: вход управления → АЦП → калибровка → расчёт блока → DMA → I2S → кодек. Код генератора и фильтра модуля Braids собран на компьютере и проверен опытом: точность строя, АЧХ фильтра, спектр пилы с полиномиальным BLEP и без него.

Опоры: проект eurorack Mutable Instruments (github.com/pichenettes/eurorack, коммит 08460a6, автор — Émilie Gillet): makefile модулей, драйверы */drivers/codec.cc, plaits/drivers/audio_dac.cc, plaits/ui.cc, plaits/settings.cc, braids/braids.cc, braids/analog_oscillator.cc, braids/svf.h, генератор таблиц braids/resources/lookup_tables.py и схемы */hardware_design/pcb/*.sch. Лицензия: код для STM32 — MIT, схемы и платы — CC-BY-SA 3.0 (README проекта); цитаты ниже — с этой пометкой. Регистры STM32F405 — по заголовку stm32f405xx.h и HAL F4 (пак STM32F4xx_DFP 2.17.1). Кроме разбора — свой маленький голос синтезатора на C: вход V/OCT с калибровкой, пила с polyBLEP, фильтр и блок для DMA, плюс настройка I2S и DMA на F405 по регистрам — целиком, для повторения. Про фильтры вообще — раздел 81а, про точную частоту I2S — 18а.

Как это называется в документации

Оригинал Перевод
control voltage (CV) напряжение управления
1 V/octave (V/OCT) один вольт на октаву: +1 В — нота на октаву выше
gate, trigger уровень «нажато» и короткий импульс запуска
codec, audio DAC кодек (АЦП + ЦАП звука), звуковой ЦАП
block size размер блока: сколько отсчётов считается за один вызов
half transfer / transfer complete прерывания DMA «половина» и «конец» буфера
phase increment шаг фазы за отсчёт
band-limited step (BLEP), polyBLEP поправка к скачку сигнала против наложения спектра
aliasing наложение спектра: гармоники выше половины частоты отсчётов «отражаются» вниз
state variable filter (SVF) фильтр с переменными состояния: ФНЧ, полосовой и ФВЧ сразу
damping, resonance затухание и резонанс фильтра; добротность Q = 1 / затухание
lookup table (LUT) таблица заранее посчитанных значений

18в.1 Модули и микроконтроллеры

Схема цифрового модуля Eurorack: вход V/OCT через ОУ с опорой −10 В в АЦП, калибровка в ноту, расчёт блоками, буфер DMA по кругу, I2S и кодек WM8731 или PCM5100, внизу — какие модули на каких STM32

Eurorack — формат модульных синтезаторов: каждый модуль — отдельная панель в общем корпусе, модули соединяются шнурами. По шнурам идут звук и напряжения управления: высота ноты (1 В на октаву), огибающие, уровни «нажато» (gate) и импульсы запуска (trigger). Питание — общая двуполярная шина корпуса; точных напряжений шины в файлах проекта нет, поэтому здесь они не называются.

Какой модуль на каком микроконтроллере, видно по FAMILY и F_CPU в makefile и по обозначениям на схемах:

Модуль Что делает МК на схеме F_CPU Выход звука
Braids генератор STM32F103CBT6 72 МГц ЦАП DAC8551 по SPI
Plaits генератор STM32F373CCT6 72 МГц PCM5100 по I2S
Clouds гранулярная обработка STM32F405RGT6 168 МГц WM8731 по I2S
Rings резонатор STM32F405RGT6 168 МГц WM8731 по I2S
Elements модальный синтез STM32F405RGT6 168 МГц WM8731 по I2S
Warps модулятор STM32F405RGT6 168 МГц WM8731 по I2S

Tides, Peaks, Frames, Streams и Yarns — тоже STM32F103 (FAMILY = f10x), Stages и Tides 2 — STM32F373 (f37x), Marbles — F405 (f4xx).

Раскладка простая. Где нужен звуковой вход (Clouds, Rings, Elements, Warps обрабатывают внешний сигнал) — F405 и кодек WM8731: АЦП и ЦАП в одном корпусе. Где звук только выходит — F373 и ЦАП PCM5100 или F103 и ЦАП DAC8551. У F373 для входов управления есть 16-битные сигма-дельта АЦП (SDADC), ими и читается V/OCT в Plaits.

Одного этого репозитория для сборки мало. Каждый makefile кончается строкой include stmlib/makefile.inc, а stmlib в проекте — подмодуль git, и в архиве репозитория его папка пуста. Поэтому ниже код Braids собран по частям — с короткой заменой тех функций stmlib, без которых он не компилируется (об этом — в 18в.4).


18в.2 Кодек, I2S и двойной буфер DMA

F405 и WM8731. Все четыре модуля на F405 подключают кодек одинаково: настройка по I2C2 (адрес 0x1A), звук — по I2S2, причём передача идёт через SPI2, а приём — через дополнительный модуль I2S2ext, как в 18а. Передачу ведёт DMA1 Stream4, приём — DMA1 Stream3, оба по кругу, полусловами. Прерывание берётся только у приёмного потока — и по нему считаются обе половины:

void DMA1_Stream3_IRQHandler(void) {
  if (AUDIO_I2S_EXT_DMA_REG->AUDIO_I2S_EXT_DMA_ISR & AUDIO_I2S_EXT_DMA_FLAG_TC) {
    AUDIO_I2S_EXT_DMA_REG->AUDIO_I2S_EXT_DMA_IFCR = AUDIO_I2S_EXT_DMA_FLAG_TC;
    rings::Codec::GetInstance()->Fill(1);
  }
  if (AUDIO_I2S_EXT_DMA_REG->AUDIO_I2S_EXT_DMA_ISR & AUDIO_I2S_EXT_DMA_FLAG_HT) {
    AUDIO_I2S_EXT_DMA_REG->AUDIO_I2S_EXT_DMA_IFCR = AUDIO_I2S_EXT_DMA_FLAG_HT;
    rings::Codec::GetInstance()->Fill(0);
  }
}

(rings/drivers/codec.cc, MIT.) Флаги читаются прямо из регистров: по комментарию автора — чтобы не тратить вызовы функций библиотеки. Fill(0) считает первую половину, пока DMA передаёт вторую, Fill(1) — наоборот. Длина буфера — 2 * stride * block_size * 2 полуслов: два канала, две половины. У Rings блок 24 кадра, при ведущем STM32 (stride = 1) это 96 полуслов.

stride — второй режим. В исправленной версии платы ведущим становится кодек, и тогда, как написано в коде, данные идут «с набивкой»: при 48 кГц каждый второй слот пустой (stride = 2), при 32 кГц — два из трёх. Fill до расчёта сдвигает отсчёты вплотную, после — раздвигает обратно.

Частота. Такт I2S даёт отдельная ФАПЧ PLLI2S. Числа N и R в драйвере подобраны «для кварца 8 МГц»; если вход ФАПЧ 1 МГц, делитель I2S (с выходом MCLK, fs = I2SCLK / (256 · делитель)) даёт то же, что написано в комментариях:

Частота N / R I2SCLK, Гц Делитель Получается, Гц Ошибка Комментарий в коде
48 000 258 / 3 86 000 000 7 47 991,07 −0,0186 % 47.992kHz
44 100 271 / 6 45 166 666 4 44 108,07 +0,0183 % 44.11kHz
32 000 426 / 4 106 500 000 13 32 001,20 +0,0038 % 32.003kHz
96 000 393 / 4 98 250 000 4 95 947,27 −0,0549 % 95.95 kHz

48 кГц с ошибкой −0,0186 % — тот же предел, что в 18а и 18б. Худший случай — 96 кГц у Warps: −0,95 цента, на слух не заметно.

То же своими руками. Ниже — настройка звука F405 по регистрам, без библиотек: ФАПЧ на 86 МГц, I2S2 — ведущий передатчик с MCLK, I2S2ext — ведомый приёмник, два потока DMA по кругу и прерывание, которое считает свободную половину. Выводы, потоки и каналы DMA — те же, что в драйвере Rings. Настройка самого кодека по I2C сюда не входит.

/* Раздел 18в: звук на STM32F405 по регистрам — PLLI2S, I2S2 (передача) и I2S2ext (приём),
 * DMA1 по кругу с двумя половинами, расчёт блока в прерывании. Выводы и потоки DMA —
 * как у кодека WM8731 в модулях Mutable Instruments: PB12 WS, PB13 CK, PB15 SD, PB14 ext_SD,
 * PC6 MCK, все AF5; передача — DMA1 Stream4 канал 0, приём — Stream3 канал 3.
 * Настройка самого кодека по I2C здесь не повторяется. Вход ФАПЧ — 1 МГц. */
#include "stm32f4xx.h"
#include "synth.h"

#define BLOCK      24u                           /* кадров в половине буфера */
#define PLLI2S_N   258u                          /* 1 МГц · 258 / 3 = 86 МГц */
#define PLLI2S_R   3u
#define I2SPR_48K  (3u | SPI_I2SPR_ODD | SPI_I2SPR_MCKOE)   /* 86 МГц / (256 · 7) = 47 991 Гц */
#define I2S_MASTER_TX (SPI_I2SCFGR_I2SMOD | SPI_I2SCFGR_I2SCFG_1)   /* Philips, 16 бит */
#define I2S_SLAVE_RX  (SPI_I2SCFGR_I2SMOD | SPI_I2SCFGR_I2SCFG_0)
#define DMA_TX_CR  ((0u << DMA_SxCR_CHSEL_Pos) | DMA_SxCR_PL_1 | DMA_SxCR_MSIZE_0 | DMA_SxCR_PSIZE_0 \
                    | DMA_SxCR_MINC | DMA_SxCR_CIRC | DMA_SxCR_DIR_0)
#define DMA_RX_CR  ((3u << DMA_SxCR_CHSEL_Pos) | DMA_SxCR_PL_1 | DMA_SxCR_MSIZE_0 | DMA_SxCR_PSIZE_0 \
                    | DMA_SxCR_MINC | DMA_SxCR_CIRC | DMA_SxCR_HTIE | DMA_SxCR_TCIE)

_Static_assert(I2SPR_48K == 0x0303u, "I2SDIV 3, ODD, MCKOE");
_Static_assert(I2S_MASTER_TX == 0x0A00u && I2S_SLAVE_RX == 0x0900u, "I2SCFGR");
_Static_assert(DMA_TX_CR == 0x00022D40u && DMA_RX_CR == 0x06022D18u, "DMA_SxCR");

static int16_t tx[2u * BLOCK * 2u];              /* 2 канала × 2 половины */
static int16_t rx[2u * BLOCK * 2u];
static synth_t voice;
static cal_t cal = { -60.0f, 25.71f };           /* значения по умолчанию, как в Plaits */
volatile int16_t voct_code;                      /* код АЦП входа V/OCT, пишет драйвер АЦП */

static void pins_af5(void)
{
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOBEN | RCC_AHB1ENR_GPIOCEN;
    GPIOB->MODER = (GPIOB->MODER & ~0xFF000000u) | 0xAA000000u;   /* PB12…PB15 — альтернативная функция */
    GPIOB->AFR[1] = (GPIOB->AFR[1] & ~0xFFFF0000u) | 0x55550000u;
    GPIOB->OSPEEDR |= 0xFF000000u;
    GPIOC->MODER = (GPIOC->MODER & ~(3u << 12)) | (2u << 12);      /* PC6 — MCK */
    GPIOC->AFR[0] = (GPIOC->AFR[0] & ~(0xFu << 24)) | (5u << 24);
    GPIOC->OSPEEDR |= 3u << 12;
}

static void plli2s_86mhz(void)
{
    RCC->CR &= ~RCC_CR_PLLI2SON;
    RCC->PLLI2SCFGR = (PLLI2S_N << RCC_PLLI2SCFGR_PLLI2SN_Pos) | (PLLI2S_R << RCC_PLLI2SCFGR_PLLI2SR_Pos);
    RCC->CFGR &= ~RCC_CFGR_I2SSRC;               /* такт I2S — от PLLI2S */
    RCC->CR |= RCC_CR_PLLI2SON;
    while (!(RCC->CR & RCC_CR_PLLI2SRDY)) { }
}

void audio_start(void)
{
    synth_init(&voice, 47991.07f);
    synth_set(&voice, 48.0f, 84.0f, 3.0f);
    pins_af5();
    plli2s_86mhz();
    RCC->APB1ENR |= RCC_APB1ENR_SPI2EN;
    RCC->AHB1ENR |= RCC_AHB1ENR_DMA1EN;

    SPI2->I2SPR = I2SPR_48K;
    SPI2->I2SCFGR = I2S_MASTER_TX;
    I2S2ext->I2SCFGR = I2S_SLAVE_RX;
    SPI2->CR2 = SPI_CR2_TXDMAEN;
    I2S2ext->CR2 = SPI_CR2_RXDMAEN;

    DMA1_Stream4->PAR = (uint32_t)&SPI2->DR;     /* передача: память -> SPI2 */
    DMA1_Stream4->M0AR = (uint32_t)tx;
    DMA1_Stream4->NDTR = 2u * BLOCK * 2u;
    DMA1_Stream4->CR = DMA_TX_CR;
    DMA1_Stream3->PAR = (uint32_t)&I2S2ext->DR;  /* приём: I2S2ext -> память */
    DMA1_Stream3->M0AR = (uint32_t)rx;
    DMA1_Stream3->NDTR = 2u * BLOCK * 2u;
    DMA1_Stream3->CR = DMA_RX_CR;
    NVIC_EnableIRQ(DMA1_Stream3_IRQn);
    DMA1_Stream3->CR |= DMA_SxCR_EN;
    DMA1_Stream4->CR |= DMA_SxCR_EN;

    I2S2ext->I2SCFGR |= SPI_I2SCFGR_I2SE;        /* ведомый — первым */
    SPI2->I2SCFGR |= SPI_I2SCFGR_I2SE;
}

/* Половина half свободна: DMA сейчас передаёт другую. */
static void fill(unsigned half)
{
    synth_set(&voice, voct_to_note(voct_code, &cal) + 36.0f, 84.0f, 3.0f);
    synth_render(&voice, &tx[half * BLOCK * 2u], BLOCK);
}

void DMA1_Stream3_IRQHandler(void)
{
    uint32_t isr = DMA1->LISR;
    if (isr & DMA_LISR_HTIF3) {
        DMA1->LIFCR = DMA_LIFCR_CHTIF3;
        fill(0);
    }
    if (isr & DMA_LISR_TCIF3) {
        DMA1->LIFCR = DMA_LIFCR_CTCIF3;
        fill(1);
    }
}

_Static_assert сверяют числа с полями заголовка: I2SPR = 0x0303 (I2SDIV = 3, ODD, MCKOE — делитель 7), I2SCFGR — 0x0A00 и 0x0900, DMA_SxCR — 0x00022D40 и 0x06022D18. arm-none-eabi-gcc собрал файл для Cortex-M4F без предупреждений: 492 байта кода, из них audio_start — 336, прерывание — 156; буферы и голос — 414 байт ОЗУ. На плате файл не запускался.

F373 и PCM5100. У F373 нет звуковой ФАПЧ: такт бита — просто делитель системной частоты. В plaits/dsp/dsp.h (MIT) автор посчитал это сам:

// Bit clock = 72000000 / 47 = 1531.91 kHz
// Frame clock = Bit clock / 32 = 47872.34 Hz
//
// That's only 4.6 cts of error, but we care!

static const float kCorrectedSampleRate = 47872.34f;

Расчёт делителя по правилу библиотеки ST для 16 бит без MCLK даёт те же 47, такт бита 1 531 914,89 Гц и частоту 47 872,34 Гц — на 4,61 цента ниже 48 кГц. Исправлять такт не стали: вместо этого все частоты нот в Plaits считаются от kCorrectedSampleRate, и строй остаётся точным. Кодек здесь не нужен вовсе: PCM5100 — только ЦАП, DMA1 Channel5 кормит SPI2 в режиме передачи, прерывания «половина» и «конец» зовут Fill(1) и Fill(0), буфер — 2 * block_size * 2 = 48 полуслов при блоке 12.

F103 и DAC8551. В Braids нет ни I2S, ни DMA для звука: таймер TIM1 с периодом F_CPU / 96000 − 1 = 749 вызывает прерывание 96 000 раз в секунду, и оно пишет один отсчёт в ЦАП по SPI2. Отсчёты лежат в кольце из четырёх блоков по 24 (audio_samples[kNumBlocks][kBlockSize]), основной цикл досчитывает следующий блок, пока прерывание играет текущий. Тот же двойной буфер, только «DMA» сделан программой.


18в.3 Вход V/OCT: ОУ, АЦП и калибровка

Напряжение управления нельзя подать на вывод STM32 напрямую: оно бывает отрицательным и больше 3,3 В. В Plaits его приводит инвертирующий сумматор на ОУ; в коде калибровки (plaits/ui.cc, MIT) автор записал его формулу в комментариях:

void Ui::CalibrateC3() {
  // (-33/100.0*1 + -33/140.0 * -10.0) / 3.3 * 2.0 - 1 = 0.228
  float c1 = cv_c1_;

  // (-33/100.0*1 + -33/140.0 * -10.0) / 3.3 * 2.0 - 1 = -0.171
  float c3 = pitch_lp_calibration_;
  float delta = c3 - c1;

  if (delta > -0.6f && delta < -0.2f) {
    ChannelCalibrationData* c = settings_->mutable_calibration_data(
        CV_ADC_CHANNEL_V_OCT);
    c->scale = 24.0f / delta;
    c->offset = 12.0f - c->scale * c1;
    settings_->SavePersistentData();
    mode_ = UI_MODE_NORMAL;
  } else {
    mode_ = UI_MODE_ERROR;
  }
  normalization_probe_.Init();
}

Читается так: вход через 100 кОм, обратная связь 33 кОм (усиление −0,33), через 140 кОм подмешана опора −10 В (на схеме — источник LM4040B10), результат в пределах 0…3,3 В переводится в число x от −1 до +1 (float_value делит 16-битный код на 32 768). Во второй строке комментария напряжение то же «1» — описка: для 3 В получается как раз −0,171. Программа посчитала формулу:

Это ровно значения по умолчанию из plaits/settings.cc: scale = -60.0f, offset = 25.71. 1 В на октаву в коде виден напрямую: 1 В → нота 12, 3 В → нота 36, два вольта — 24 полутона.

Калибровка. Владелец подаёт 1 В и 3 В, модуль запоминает показания (CalibrateC1, CalibrateC3) и пишет в память scale и offset для V/OCT; для остальных входов по тем же шагам снимается только смещение. Проверка delta > -0.6f && delta < -0.2f отбрасывает явную ошибку — например, перепутанные напряжения. Хранятся данные в PersistentData через модуль хранения во флеш из подмодуля stmlib (здесь не разбирается).

То же в своём коде (synth.c):

/* 16-битный код АЦП -> x от -1 до +1, как float_value() в Plaits */
float voct_x(int16_t code)
{
    return (float)code / 32768.0f;
}

/* Калибровка по двум точкам: 1 В -> нота 12, 3 В -> нота 36 (24 полутона на 2 В).
 * Возвращает 0 и ничего не меняет, если разность не похожа на правду. */
int cal_two_points(cal_t *c, float x_1v, float x_3v)
{
    float delta = x_3v - x_1v;
    if (!(delta > -0.6f && delta < -0.2f))
        return 0;
    c->scale = 24.0f / delta;
    c->offset = 12.0f - c->scale * x_1v;
    return 1;
}

float voct_to_note(int16_t code, const cal_t *c)
{
    return voct_x(code) * c->scale + c->offset;
}

float note_to_hz(float note)
{
    return 440.0f * exp2f((note - 69.0f) / 12.0f);
}

Опыт на компьютере: коды АЦП для 1 В и 3 В взяты из той же модели входа — 7490 и −5617. Калибровка дала scale = −60,0009, offset = 25,7148; перепутанные 1 В и 3 В отвергнуты. На 0…5 В с шагом 1 мВ нота отличается от 12 · V не больше чем на 0,193 цента — это округление кода АЦП до 16 бит.

Зачем калибровать, видно по числам. Если вход не 100 кОм, а 101 кОм (допуск 1 %), наклон меняется, и без калибровки строй уходит на −11,88 цента на октаву — −59,4 цента на пяти октавах. Шаг 16-битного кода — 60 / 32 768 полутона = 0,183 цента; это на порядки меньше.


18в.4 Генератор: одна октава таблицы и точность строя

В Braids высота — целое число в 1/128 полутона (pitch_ = 60 << 7 — нота 60), фаза — 32 бита, частота отсчётов 96 кГц. Шаг фазы берётся из таблицы lut_oscillator_increments, посчитанной заранее lookup_tables.py — в ней 97 чисел на одну октаву выше всего диапазона, шаг таблицы 1/8 полутона. Ноты ниже получаются сдвигом вправо — делением на 2 за октаву:

uint32_t AnalogOscillator::ComputePhaseIncrement(int16_t midi_pitch) {
  if (midi_pitch >= kHighestNote) {
    midi_pitch = kHighestNote - 1;
  }

  int32_t ref_pitch = midi_pitch;
  ref_pitch -= kPitchTableStart;

  size_t num_shifts = 0;
  while (ref_pitch < 0) {
    ref_pitch += kOctave;
    ++num_shifts;
  }

  uint32_t a = lut_oscillator_increments[ref_pitch >> 4];
  uint32_t b = lut_oscillator_increments[(ref_pitch >> 4) + 1];
  uint32_t phase_increment = a + \
      (static_cast<int32_t>(b - a) * (ref_pitch & 0xf) >> 4);
  phase_increment >>= num_shifts;
  return phase_increment;
}

(braids/analog_oscillator.cc, MIT.) Программа пересчитала таблицу по формуле из lookup_tables.py — совпали все 97 чисел. Таблицы фильтра (lut_svf_cutoff, lut_svf_damp, по 257 чисел) отличаются от формулы не больше чем на единицу — это округление.

Как собран опыт. Файлы analog_oscillator.cc, svf.h и нужные таблицы из resources.cc скопированы без изменений. Вместо пустого stmlib — замена на три десятка строк: целые типы, макросы CLIP и DISALLOW_COPY_AND_ASSIGN и функции таблиц Interpolate824, Interpolate88, Crossfade. Пилу и ComputePhaseIncrement замена не затрагивает; фильтр берёт из неё только линейную интерполяцию по таблице. g++ собрал всё без предупреждений.

Точность строя. Программа перебрала все высоты с шагом 1/128 полутона и сравнила частоту из шага фазы (шаг · 96 000 / 2³²) с точной 440 · 2^((n − 69)/12):

Одной октавы в таблице хватает: сдвиг теряет младшие биты шага, но шаг велик — у ноты 36 это 2 926 232.


18в.5 Пила без наложения спектра: полиномиальный BLEP

Пила «в лоб» — старшие биты фазы — на каждом периоде резко падает. Резкий скачок содержит гармоники до бесконечности, и всё, что выше 48 кГц, отражается вниз, в слышимую полосу, нестройным шумом. Braids сглаживает скачок: в двух соседних отсчётах вокруг сброса фазы к сигналу прибавляется поправка — квадратичный полином от того, где между отсчётами был скачок:

  inline int32_t ThisBlepSample(uint32_t t) {
    if (t > 65535) {
      t = 65535;
    }
    return t * t >> 18;
  }

  inline int32_t NextBlepSample(uint32_t t) {
    if (t > 65535) {
      t = 65535;
    }
    t = 65535 - t;
    return -static_cast<int32_t>(t * t >> 18);
  }

(braids/analog_oscillator.h, MIT.) t — доля отсчёта, прошедшая после переполнения фазы, в 16 битах: phase_ / (phase_increment >> 16).

Опыт. Программа отрендерила настоящим RenderSaw по 65 536 отсчётов блоками по 24 и рядом — ту же фазу без поправки. Спектр — окно Блэкмана — Харриса; «гармоники» — полосы вокруг k · f0 ниже 48 кГц, всё прочее — наложение:

Нота, частота Гармоник до 48 кГц Наложение к гармоникам: без / с BLEP Худшая побочная до 20 кГц: без / с BLEP
96, 2093,0045 Гц 22 −15,6 / −31,1 дБ −31,8 / −58,0 дБ
84, 1046,5022 Гц 45 −18,7 / −34,5 дБ −36,7 / −61,6 дБ

Поправка в две строки убирает около 15 дБ мощности наложения и 26 дБ у самой заметной побочной составляющей. Плата — завал верха: у ноты 96 последняя, 22-я гармоника без поправки −26,8 дБ (как у идеальной пилы, 1/k), с поправкой −34,0 дБ; у ноты 84 45-я — −33,2 и −40,7 дБ.

Свой вариант в float — классический polyBLEP: та же идея, поправка — квадратичный полином от доли отсчёта, фаза — от 0 до 1:

/* Поправка к скачку пилы: t — фаза 0…1, dt — шаг фазы за отсчёт */
float polyblep(float t, float dt)
{
    if (t < dt) {                 /* отсчёт сразу после скачка */
        t /= dt;
        return t + t - t * t - 1.0f;
    }
    if (t > 1.0f - dt) {          /* отсчёт перед скачком */
        t = (t - 1.0f) / dt;
        return t * t + t + t + 1.0f;
    }
    return 0.0f;
}
float synth_saw(synth_t *s)
{
    float y = 2.0f * s->phase - 1.0f - polyblep(s->phase, s->inc);
    s->phase += s->inc;
    if (s->phase >= 1.0f)
        s->phase -= 1.0f;
    return y;
}

float synth_saw_naive(synth_t *s)
{
    float y = 2.0f * s->phase - 1.0f;
    s->phase += s->inc;
    if (s->phase >= 1.0f)
        s->phase -= 1.0f;
    return y;
}

При 48 кГц у ноты 96 (2093,0044 Гц) ниже 24 кГц всего 11 гармоник, и наложение сильнее, чем у Braids на 96 кГц: без поправки −12,5 дБ к мощности гармоник и −22,5 дБ у худшей побочной; с поправкой — −28,4 и −34,9 дБ. Та же плата: 11-я гармоника −27,6 дБ вместо −20,5 дБ.

arm-none-eabi-g++ (-mcpu=cortex-m3 -Os) собрал RenderSaw в 322 байта кода, ComputePhaseIncrement — в 64, весь analog_oscillator.cc — в 2954 байта; таблицы, которые он использует, — 11 692 байта.


18в.6 Фильтр SVF в целых числах и срок блока

Фильтр Braids (braids/svf.h, MIT) — классическая схема с переменными состояния в 16-битной арифметике: ФНЧ, полосовой и ФВЧ считаются одним проходом.

    int32_t notch = in - (bp_ * damp >> 15);
    lp_ += f * bp_ >> 15;
    CLIP(lp_)
    int32_t hp = notch - lp_;
    bp_ += f * hp >> 15;
    CLIP(bp_)

Частота среза задаётся нотой (как высота генератора), коэффициент f берётся из таблицы lut_svf_cutoff = 2 · sin(π · fc / 96 000), затухание — из lut_svf_damp. Выше fs / 8 = 12 000 Гц коэффициент не растёт: первая «упёртая» нота — 127 (12 543,9 Гц).

Опыт. Программа подавала синус и перебирала частоту шагом 1/48 октавы, у пика — 1/384 октавы (3,1 цента). Добротность по таблице: при resonance = 16384 затухание 10 639 / 32 767 = 0,3247, Q = 3,08; при 28 000 — 0,0845, Q = 11,83. У аналогового ФНЧ второго порядка с такой Q пик должен стоять на −46,9 и −3,1 цента ниже fc, высотой 9,89 и 21,47 дБ. Вышло:

Нота, fc Q Пик ФНЧ На fc На 4·fc
60, 261,6 Гц 3,08 −46,9 цента, 10,00 дБ 9,89 дБ −18,86 дБ
84, 1046,5 Гц 3,08 −34,4 цента, 9,89 дБ 9,81 дБ −21,51 дБ
84, 1046,5 Гц 11,83 0 центов, 21,47 дБ 21,47 дБ −20,54 дБ
108, 4186,0 Гц 11,83 +6,2 цента, 21,51 дБ 21,47 дБ −21,72 дБ
120, 8372,0 Гц 11,83 +18,8 цента, 21,71 дБ 21,47 дБ −19,17 дБ

Подъём на fc совпадает с Q по таблице: при Q = 11,83 — от 21,47 до 21,49 дБ против 21,46 дБ на всех нотах 48…120, при Q = 3,08 — от 9,78 до 9,97 дБ против 9,77 дБ. Пик к верху диапазона уходит вверх на десятки центов — цифровой SVF при fc, сравнимой с частотой отсчётов, уже не аналоговый. В полосе пропускания (fc / 8) усиление от +0,15 до +0,96 дБ, больше у низких нот: f мало, и целочисленное состояние округляется грубо. Выход ни разу не дошёл до насыщения — наибольший отсчёт 12 173.

Свой фильтр и блок. Та же схема Чемберлина в float, расчёт блока — стерео 16 бит, прямо в половину буфера DMA:

void synth_init(synth_t *s, float fs)
{
    s->phase = 0.0f;
    s->inc = 0.0f;
    s->lp = s->bp = 0.0f;
    s->f = 0.0f;
    s->damp = 1.0f;
    s->fs = fs;
}

void synth_set(synth_t *s, float note, float cutoff_note, float q)
{
    float fc = note_to_hz(cutoff_note);
    if (fc > s->fs / 8.0f)        /* выше fs/8 этот фильтр неустойчив — как в Braids */
        fc = s->fs / 8.0f;
    s->inc = note_to_hz(note) / s->fs;
    s->f = 2.0f * sinf(3.14159265f * fc / s->fs);
    s->damp = 1.0f / q;
}
/* Фильтр с переменными состояния (Чемберлин): ФНЧ, полосовой и ФВЧ за один проход */
float synth_svf_lp(synth_t *s, float in)
{
    float notch = in - s->damp * s->bp;
    s->lp += s->f * s->bp;
    float hp = notch - s->lp;
    s->bp += s->f * hp;
    return s->lp;
}

/* Блок для DMA: frames кадров стерео, 16 бит, левый и правый одинаковые */
void synth_render(synth_t *s, int16_t *out, size_t frames)
{
    while (frames--) {
        float y = synth_svf_lp(s, 0.25f * synth_saw(s));
        if (y > 1.0f) y = 1.0f;
        if (y < -1.0f) y = -1.0f;
        int16_t v = (int16_t)(y * 32767.0f);
        *out++ = v;
        *out++ = v;
    }
}

Объявления и состояние голоса — synth.h:

/* Раздел 18в: маленький голос синтезатора — V/OCT, генератор пилы с polyBLEP,
 * фильтр SVF и расчёт блока. Переносимый C: собирается и на компьютере, и под STM32F405. */
#ifndef SYNTH_H
#define SYNTH_H
#include <stdint.h>
#include <stddef.h>

typedef struct {        /* калибровка входа V/OCT: нота = x * scale + offset */
    float scale, offset;
} cal_t;

typedef struct {
    float phase, inc;   /* фаза 0…1 и шаг за отсчёт */
    float lp, bp;       /* состояние фильтра */
    float f, damp;      /* 2·sin(π·fc/fs) и 1/Q */
    float fs;
} synth_t;

float voct_x(int16_t code);
int cal_two_points(cal_t *c, float x_1v, float x_3v);
float voct_to_note(int16_t code, const cal_t *c);
float note_to_hz(float note);
float polyblep(float t, float dt);
void synth_init(synth_t *s, float fs);
void synth_set(synth_t *s, float note, float cutoff_note, float q);
float synth_saw(synth_t *s);
float synth_saw_naive(synth_t *s);
float synth_svf_lp(synth_t *s, float in);
void synth_render(synth_t *s, int16_t *out, size_t frames);

#endif

У float-фильтра подъём на fc — ровно 20 · lg Q: 9,54 дБ при Q = 3 и 21,58 дБ при Q = 12 (fc = 1046,5 Гц, 48 кГц). 2000 блоков по 24 кадра — пик 16 857, до предела не дошло, каналы одинаковые. Весь synth.c для Cortex-M4F — 944 байта кода, synth_render — 292.

Цепочка. Пила ноты 48 через ФНЧ с resonance = 20000, частота среза за секунду едет от ноты 72 до 108, всё блоками по 24 — 4000 блоков, WAV 96 000 Гц, 16 бит, моно, 96 000 кадров, пик 19 120 без насыщения. Центр тяжести спектра — 381 Гц в первой четверти и 669 Гц в последней: фильтр открывается, звук становится ярче.

Срок блока. Всё, что модуль считает, должно уложиться в длительность блока — иначе DMA догонит ещё не заполненную половину, и будет щелчок. Частоты и блоки — из кода модулей, такты — из F_CPU:

Модуль МК Частота Блок Срок Тактов на блок Тактов на отсчёт
Braids F103, 72 МГц 96 000 24 0,250 мс 18 000 750
Plaits F373, 72 МГц 48 000 12 0,250 мс 18 000 1500
Clouds F405, 168 МГц 32 000 32 1,000 мс 168 000 5250
Elements F405, 168 МГц 32 000 16 0,500 мс 84 000 5250
Rings F405, 168 МГц 48 000 24 0,500 мс 84 000 3500
Warps F405, 168 МГц 96 000 60 0,625 мс 105 000 1750

Видна логика выбора. Самые тяжёлые алгоритмы (Clouds, Elements) работают на 32 кГц — так на отсчёт приходится 5250 тактов. Блоки маленькие — 0,25…1 мс: это и задержка отклика на вход управления, а управляющие входы читаются раз в блок. У Braids на отсчёт всего 750 тактов, и отсюда целочисленный код и таблицы вместо sin и pow. Сколько тактов реально тратит каждый модуль, здесь не измерено: платы не было.


18в.7 Грабли

Нота уходит на несколько центов, хотя таблицы точные. Частота отсчётов не та, что в коде: у F373 делитель I2S целый, и 48 кГц превращаются в 47 872,34 Гц (−4,61 цента). Считать шаги нот надо от настоящей частоты, как kCorrectedSampleRate в Plaits.

Строй «плывёт» по октавам. Наклон входа V/OCT задан резисторами: 1 % разницы — около 12 центов на октаву. Лечится только калибровкой по двум напряжениям (1 В и 3 В) с записью scale и offset во флеш.

Калибровка «съела» ошибку и сохранилась. Без проверки разумности перепутанные 1 В и 3 В дали бы отрицательный наклон. В Plaits окно -0.6 < delta < -0.2 не пускает такие данные в память.

Звенит на высоких нотах. Пила или меандр без поправки на скачке: наложение спектра. Полиномиальный BLEP в две строки убирает около 15 дБ; цена — тише верхние гармоники.

Щелчки при тяжёлом режиме. Расчёт блока не уложился в срок. Прерывание DMA приходит каждые block / fs секунд — у Rings каждые 0,5 мс — и половина буфера должна быть готова к следующему.

Кодек ведущий — звук через отсчёт. В режиме, где такт даёт WM8731, слоты идут с набивкой; без раздвигания отсчётов (stride) звук окажется вдвое быстрее или с нулями между отсчётами.

Первый блок после смены формы — не той высоты. В Braids Render при смене формы зовёт Init(), а та ставит ноту 60. Высоту задают после первого вызова.

Прошивка не собирается из скачанного архива. Подмодули git (stmlib, загрузчики) не скачаны: include stmlib/makefile.inc не находит файл.


18в.8 Итоги

Полные исходники

synth.c — голос синтезатора: приёмы Plaits и Braids, свой код во float.

/* Раздел 18в: голос синтезатора. Приёмы — как в Plaits и Braids, код свой, в float. */
#include <math.h>
#include "synth.h"

/* 16-битный код АЦП -> x от -1 до +1, как float_value() в Plaits */
float voct_x(int16_t code)
{
    return (float)code / 32768.0f;
}

/* Калибровка по двум точкам: 1 В -> нота 12, 3 В -> нота 36 (24 полутона на 2 В).
 * Возвращает 0 и ничего не меняет, если разность не похожа на правду. */
int cal_two_points(cal_t *c, float x_1v, float x_3v)
{
    float delta = x_3v - x_1v;
    if (!(delta > -0.6f && delta < -0.2f))
        return 0;
    c->scale = 24.0f / delta;
    c->offset = 12.0f - c->scale * x_1v;
    return 1;
}

float voct_to_note(int16_t code, const cal_t *c)
{
    return voct_x(code) * c->scale + c->offset;
}

float note_to_hz(float note)
{
    return 440.0f * exp2f((note - 69.0f) / 12.0f);
}

/* Поправка к скачку пилы: t — фаза 0…1, dt — шаг фазы за отсчёт */
float polyblep(float t, float dt)
{
    if (t < dt) {                 /* отсчёт сразу после скачка */
        t /= dt;
        return t + t - t * t - 1.0f;
    }
    if (t > 1.0f - dt) {          /* отсчёт перед скачком */
        t = (t - 1.0f) / dt;
        return t * t + t + t + 1.0f;
    }
    return 0.0f;
}

void synth_init(synth_t *s, float fs)
{
    s->phase = 0.0f;
    s->inc = 0.0f;
    s->lp = s->bp = 0.0f;
    s->f = 0.0f;
    s->damp = 1.0f;
    s->fs = fs;
}

void synth_set(synth_t *s, float note, float cutoff_note, float q)
{
    float fc = note_to_hz(cutoff_note);
    if (fc > s->fs / 8.0f)        /* выше fs/8 этот фильтр неустойчив — как в Braids */
        fc = s->fs / 8.0f;
    s->inc = note_to_hz(note) / s->fs;
    s->f = 2.0f * sinf(3.14159265f * fc / s->fs);
    s->damp = 1.0f / q;
}

float synth_saw(synth_t *s)
{
    float y = 2.0f * s->phase - 1.0f - polyblep(s->phase, s->inc);
    s->phase += s->inc;
    if (s->phase >= 1.0f)
        s->phase -= 1.0f;
    return y;
}

float synth_saw_naive(synth_t *s)
{
    float y = 2.0f * s->phase - 1.0f;
    s->phase += s->inc;
    if (s->phase >= 1.0f)
        s->phase -= 1.0f;
    return y;
}

/* Фильтр с переменными состояния (Чемберлин): ФНЧ, полосовой и ФВЧ за один проход */
float synth_svf_lp(synth_t *s, float in)
{
    float notch = in - s->damp * s->bp;
    s->lp += s->f * s->bp;
    float hp = notch - s->lp;
    s->bp += s->f * hp;
    return s->lp;
}

/* Блок для DMA: frames кадров стерео, 16 бит, левый и правый одинаковые */
void synth_render(synth_t *s, int16_t *out, size_t frames)
{
    while (frames--) {
        float y = synth_svf_lp(s, 0.25f * synth_saw(s));
        if (y > 1.0f) y = 1.0f;
        if (y < -1.0f) y = -1.0f;
        int16_t v = (int16_t)(y * 32767.0f);
        *out++ = v;
        *out++ = v;
    }
}

Дальше: раздел 18г «RFID-считыватель RC522: MFRC522 по SPI, поиск карты, UID, антиколлизия, чтение блоков MIFARE Classic» — ещё одно устройство на SPI: считыватель бесконтактных карт.

Открыть в интерактивном справочнике