Несколько лет назад я купил MIDI-клавиатуру. И как это часто бывает с вещами, о которых долго мечтал: поигрался, сбил свой интерес и положил пылиться на полку. Но вот недавно у меня возникло желание сделать из MIDI-клавиатуры синтезатор.

Многие могут спросить: а в чём проблема? Взял МIDI-клавиатуру, подключил к компьютеру или смартфону с программой и играй сколько хочешь. Но у меня другая цель – понять, как нажатие на клавишу превращается в звук.

В статье мы разберём основы цифрового звука, формат MIDI и создадим простой музыкальный синтезатор на базе Raspberry Pi Pico и MIDI-клавиатуры.

Статья является введением в синтезирование цифрового звука, и в будущем я планирую продолжить излагать эту интересную тему.

Что получилось на данный момент, можно посмотреть на следующем видео:

Что представляет собой цифровой звук

Чтобы понять, что такое цифровой звук, сначала разберёмся, как аналоговый звуковой сигнал преобразуется в цифровую форму и из цифровой в аналоговую.

Цифро-аналоговое и аналогово цифровые преобразования
Цифро-аналоговое и аналогово цифровые преобразования

Цифровой звук представляет собой последовательность чисел, описывающих амплитуду звукового сигнала в отдельные моменты времени.

С точки зрения программиста, это последовательность чисел, созданная из аналогового сигнала. Каждое число — значение амплитуды сигнала в определённый момент времени.

Чтобы получить эту последовательность, необходимо выполнить дискретизацию по времени и квантование по уровню. Этот процесс называется аналого-цифровым преобразованием.

Обратный процесс получения звуковых колебаний из последовательности чисел называется цифро-аналоговым преобразованием.

Аналогово-цифровое и цифро-аналоговое преобразования не ограничиваются только звуковыми сигналами.

Дискретизация

Дискретизация подразумевает определение моментов времени, когда измеряется значение отцифровываемого аналогового сигнала. Для цифрового звука, используемого в CD-Audio, значения сигнала определяются 44100 раз в секунду.

Квантование

Квантование — это процесс преобразования непрерывной (аналоговой) величины в дискретную, при котором весь диапазон изменения аналогового сигнала разбивается на конечное число интервалов, и каждому интервалу присваивается определённое фиксированное значение (уровень квантования).

Чем выше частота дискретизации и чем больше уровней квантования используется, тем точнее цифровое представление соответствует исходному аналоговому сигналу.

PCM

PCM (Pulse Code Modulation, импульсно-кодовая модуляция) — это способ представления цифрового звука, при котором сигнал хранится как последовательность отсчётов фиксированной разрядности, полученных через равные интервалы времени.

Для получения потока цифр из аналогового сигнала используются два параметра:

  • Sample rate (сколько отсчётов производится в секунду).

  • Bit depth (количество уровней квантования).

При хранении и обработке PCM-данных возникает несколько практических вопросов: использовать знаковые или беззнаковые числа, какой порядок байтов выбрать для многобайтовых отсчётов и какой формат представления поддерживает используемое оборудование.

Принцип работы цифрового синтезатора

В простейшем wavetable-синтезаторе основу генерации звука составляют две сущности: осциллятор и таблица отсчетов (wavetable).

Осциллятор

Осциллятор — это «генератор колебаний», создающий колебания определённой частоты.

У осциллятора есть фаза (положение внутри волны).

Каждый маленький кусочек времени (сэмпл) он немного продвигает фазу вперёд.

Используя текущее значение фазы, осциллятор вычисляет значение сигнала: либо по математической формуле, либо выбирает соответствующий отсчёт из wavetable.

Эти действия выполняются для каждого выходного сэмпла. Поэтому при частоте дискретизации 44,1 кГц осциллятор вычисляет 44100 значений сигнала каждую секунду.

Wavetable

Wavetable хранит значения семплов для одного периода звуковой волны.

Если считывать значения из таблицы быстрее, период волны станет короче, а частота звука увеличится. Если медленнее — период увеличится и частота уменьшится.

Вероятно, вы наблюдали это, когда проигрывали звук в ускоренном режиме.

Поэтому одного периода волны достаточно для генерации звука практически любой высоты.

Фазовый аккумулятор

Фазовый аккумулятор — это переменная, в которой хранится текущее положение внутри периода волны. После генерации каждого семпла к ней прибавляется постоянное значение — приращение фазы (phase increment). Когда значение достигает конца периода, оно начинает отсчёт заново.

Фазовый аккумулятор может быть целочисленным или вещественным. Я использовал целочисленный вариант.

Как генерируется аналоговый сигнал из цифрового

Для получения аналогового звука из цифрового используется цифро-аналоговый преобразователь (Digital Analog Converter или DAC)

Выполнить цифро-аналоговое преобразование можно несколькими способами:

  1. Преобразовать цифровой сигнал в ШИМ-сигнал.

  2. Вывести цифровой сигнал на GPIO-пины и при помощи внешнего 2R Ladder DAC преобразовать в аналоговый сигнал.

  3. Вывести цифровой сигнал через I2S-интерфейс и при помощи DAC, поддерживающего I2S получить аналоговый сигнал.

Я остановился на последнем способе, потому что в большинстве случаев можно получить звук в лучшем качестве.

I2S

I2S — это стандарт последовательной шины для подключения аудиоустройств, разработанный Philips Semiconductor (сейчас NXP Semiconductor) в 1986 году. Подробно стандарт изложен в спецификации.

Cтандартом определяется 3 линии для передачи сигналов.

Название линии

Назначение

CLK

Тактовый сигнал битовой синхронизации

WS

Тактовый сигнал фреймовой синхронизации

SD

Сигнал с данными для левого и правого канала

Сигналы I2S
Сигналы I2S

Временная диаграмма для сигналов приведена ниже.

Временная диаграмма для сигналов I2S
Временная диаграмма для сигналов I2S

Что такое MIDI

Вы ознакомились в общих чертах, как генерируется цифровой звук. Но мы делаем синтезатор с MIDI-клавиатурой, которая управляет синтезом звука, поэтому дальше рассмотрим, что такое MIDI.

MIDI (Musical Instrument Digital Interface) — это стандарт, который описывает коммуникационный протокол, цифровой интерфейс и электрические разъёмы для подключения различных электронных музыкальных интерфейсов и других устройств для проигрывания, редактирования и записи музыки.

Стандартный MIDI определяет 16 независимых каналов.

Каждый канал можно рассматривать как отдельный логический поток данных. Это позволяет одному MIDI-соединению одновременно управлять несколькими инструментами.

Например:

  • Канал 1 — фортепиано.

  • Канал 2 — бас.

  • Канал 3 — струнные.

Я использовал только один канал.

Подключение MIDI-клавиатуры к микроконтроллеру

Классический MIDI-интерфейс

Классический MIDI-интерфейс использует DIN разъём, и сигнал передаётся с использованием токовой петли. Токовая петля подразумевает гальваническую развязку между MIDI-клавиатурой и MIDI-контроллером. Но из-за токовой петли нельзя просто так выводы MIDI-клавиатуры соединить с пинами микроконтроллера, нужно собрать небольшую схему с оптроном, которая делает гальваническую развязку.

На цифровом уровне MIDI-интерфейс – это UART со следующими параметрами:

  • скорость передачи — 31250 бит/с,

  • 8 бит данных,

  • 1 стоп-бит,

  • контроль чётности (Parity) отсутствует.

Или кратко: UART c режимом 8N1 и скоростью 31250 бод.

USB host MIDI-интерфейс

Если классический MIDI использует отдельный 5-контактный DIN-разъём и UART со скоростью 31250 бод, то USB MIDI передаёт сообщения MIDI поверх USB. В этом случае MIDI-клавиатура является USB-устройством, а компьютер или микроконтроллер, к которому она подключается, — USB-хостом.

Я уже рассказывал о том, как устроен USB и как с ним работать в своих предыдущих статьях, поэтому можете их почитать или обратиться к спецификациям USB.

Интересно, но MIDI-клавиатура не является HID-устройством и использует Bulk Transfer, а не Interrupt Transfer, как обычная USB-клавиатура.

MIDI-сообщения

MIDI-сообщение — это последовательность из одного или нескольких байтов, которая содержит команду для MIDI-устройства. Команда может указывать, какую ноту нужно воспроизвести, с какой силой была нажата нота, какой инструмент выбрать или как изменить значение того или иного параметра синтезатора.

Первым байтом MIDI-сообщения является так называемый байт статуса (status byte). Байт статуса определяет MIDI-канал, которому адресуется MIDI-сообщение, тип MIDI-сообщения и как интерпретировать остальные байты сообщения.

В моём простом синтезаторе я обрабатываю только два типа сообщений Note On и Note Off и использую только один канал.

Формат сообщений Note On и Note Off.

Сообщение Note On состоит из трёх байтов.

Байт

Назначение

Диапазон

Статусный байт

Тип сообщения и номер MIDI-канала

0x90–0x9F

Data Byte 1

Номер ноты (Note Number)

0–127

Data Byte 2

Velocity (скорость нажатия)

0–127

Поле velocity в MIDI-сообщении означает скорость от 1 до 127, с которой была нажата клавиша. Значение 0 означает, что клавиша отпущена. В простейшем случае можно считать, что это громкость звучания ноты, но если копнуть глубже, это могут быть и другие параметры, яркость тембра или атаку звука или несколько параметров вместе.

В общем случае нота — это звук определённой частоты, а музыка — это смена нот различной длительности.

Как видно из таблицы соответствия MIDI-нот, стандарт поддерживает 128 номеров нот (от 0 до 127), тогда как стандартное фортепиано имеет 88 клавиш. Благодаря этому диапазон MIDI значительно шире диапазона большинства клавишных инструментов. Например, синтезатор может воспроизводить очень низкие или очень высокие ноты, недоступные на обычном пианино.

По такой несложной формуле:

f = 440 \cdot 2^{\frac{n - 69}{12}}

где:

  • f(n) — частота в герцах (Гц)

  • n — номер MIDI-ноты (0–127)

  • 440 Гц — частота ноты A4 (MIDI 69)

можно вычислить частоту по номеру MIDI-ноты.

Частота MIDI-ноты 0–8.18 Гц. При всём своём желании вы её не услышите, так как это инфразвук.

Сообщение Note Off имеет практически такую же структуру.

Байт

Назначение

Диапазон

Статусный байт

Тип сообщения и номер MIDI-канала

0x80–0x8F

Data Byte 1

Номер ноты (Note Number)

0–127

Data Byte 2

Velocity (скорость отпускания)

0–127

Второй байт также называется Velocity, но в этом случае он отражает скорость отпускания клавиши (Release Velocity). На практике большинство MIDI-клавиатур всегда передают значение 0, а многие синтезаторы вообще игнорируют этот параметр. По этой причине вместо отдельного сообщения Note Off часто используется сообщение Note On с Velocity, равной нулю. Такой подход позволяет сократить количество различных типов сообщений и широко применяется в современных MIDI-устройствах.

Реализация

После того как я загрузил вас теорией, расскажу, как я реализовал свой простой синтезатор на базе MIDI-клавиатуры, Raspberry Pi Pico, и модуля I2S DAC.

В зависимости от того, как мы планируем использовать синтезатор, набор компонентов будет различаться.

У меня MIDI-клавиатура Arturia MiniLAB 3, которая поддерживает подключение к устройству как через классический MIDI-интерфейс, так и через USB host MIDI-интерфейс.

Подключение по классическому MIDI-интерфейсу

Подключить напрямую MIDI-клавиатуру к пинам Pico не получится. Так как используется токовая петля.

Необходимо собрать небольшую схему на базе оптрона (компонента, который выполняет гальваническую развязку с помощью передачи сигнала светом).

Понадобятся:

  • оптрон H11L1,

  • Резистор на 220 Ом,

  • Резистор на 470 Ом,

  • Диод 1N4148,

  • Штекер DIN-5 pin,

  • Соединительные провода,

  • Макетная плата и Dupont вилки, если будете собирать на макетке, как я.

Можно найти различные микросхемы с оптронами, я выбрал H11L1, так как он лучше подходит для 3v3 напряжения Pico.

Распиновка DIN-разъёма, используемого в MIDI:

Распиновка DIN-разъёма
Распиновка DIN-разъёма

Схема выглядит следующим образом:

Схема гальванической развязки MIDI-клавиатуры и устройства
Схема гальванической развязки MIDI-клавиатуры и устройства

Чтобы можно было обрабатывать MIDI-сообщения в программном коде на Raspberry Pi Pico нужно настроить UART, а пока разберёмся, как подключиться через USB host MIDI-интерфейс.

Подключение по USB-MIDI интерфейсу

MIDI-клавиатура с USB-интерфейсом, является USB-устройством, поэтому для физического подключения клавиатуры к Raspberry Pi Pico понадобятся, только USB-кабель и ОТG-переходник, чтобы можно было Type A вилку соединить с MicroUSB разъёмом Pico.

Так как Pico является хостом и должен запитывать USB клавиатуру, на пин Raspberry Pi Pico VBUS нужно подать внешнее питание 5В.

Для обработки MIDI-сообщений в программном коде, используем библиотеку TinyUSB.

На момент написания статьи Pico SDK поставляется с версией TinyUSB, которая не поддерживает MIDI USB HOST, поэтому подключаем более свежую библиотеку.

Подключение I2S-модуля

На реальных устройствах линии могут называться по-другому

Приведу для I2S-модулей, которые были у меня: MAX98357A и PCM5102A

Спецификация

MAX98357A

PCM5102A

CLK

BCLK

BCK

WS

LRC

LRCK

SD

DIN

DIN

Модуль на основе MAX98357A
Модуль на основе MAX98357A
Модуль на основе PCM5102A
Модуль на основе PCM5102A

Модуль на основе MAX98357A отличается от PCM5102A тем, что он имеет усилитель аудиосигнала, т. е. его сразу можно подключать к динамикам. Но звучание модуля на основе PCM5102A лучше.

Перед подключением модуля PCM5102A нужно проверить перемычки на обратной стороне модуля. Перемычки должны быть установлены следующим образом:

Перемычки на обратной стороне модуля PCM5102A
Перемычки на обратной стороне модуля PCM5102A

Расположение перемычек выделено красным прямоугольником. Модуль может быть с установленными перемычками, а может вам придётся их пропаять.

На лицевой стороне должна быть пропаяна перемычка, выделенная красным прямоугольником.

Перемычка на лицевой стороне модуля PCM5102A
Перемычка на лицевой стороне модуля PCM5102A

Raspberry Pi Pico не поддерживает интерфейс I2S аппаратно, но, благодаря PIO, его несложно реализовать на GPIO-пинах. А ещё проще — в состав pico-extras входит библиотека pico_audio_i2s, в которой уже реализован низкоуровневый функционал для I2S.

Для PCM5102A и моей прошивки, схема подключения будет следующая :

Raspberry Pi Pico

Пин модуля PCM5102A

Назначение

V3V

VIN

Питание 3,3 В

GND

GND

Земля

GP9

DIN

Сигнал с данными для левого и правого каналов

GP10

BCK

Тактовый сигнал битовой синхронизации

GP11

LCK

Тактовый сигнал фреймовой синхронизации

Схема устройства

В результате собрал следущее устройство на макетной плате. Устройство позволяет подключать MIDI как через классический интерфейс, так и через USB.

Схема устройства
Схема устройства

Чтение данных, передаваемых по стандартному MIDI-интерфейсу

Чтобы можно было получать данные по стандартному MIDI-интерфейсу, как я говорил ранее, нужно подключать MIDI клавиатуру через гальваническую развязку на базе оптрона.

Порт MIDI-OUT является однонаправленным (сообщения только шлются MIDI-клавиатурой устройству). Поэтому на микроконтроллере используем только пин с функцией UART RX.

У Raspberry Pi Pico два аппаратных UART, один я задействовал для отладки, поэтому используем второй (uart1).

Читать данные из UART-порта несложно. Нужно указать скорость 31250 бод для UART-порта, какой пин у нас используется для приёма данных по UART и в цикле проверять, если новые данные при помощи функции uart_is_readable(), и считывать при помощи функции uart_getc().

#include <stdio.h>

#include "hardware/uart.h"
#include "pico/stdlib.h"

#define UART_ID uart1
#define BAUD_RATE 31250
#define UART_RX_PIN 5

int main() {
  uart_init(UART_ID, BAUD_RATE);
  gpio_set_function(UART_RX_PIN, GPIO_FUNC_UART);
  while (true) {
    if (uart_is_readable(UART_ID)) {
      char ch = uart_getc(UART_ID);
      printf("Received code: %x\n", ch, ch);
    }
  }
}

Чтение данных, передаваемых по MIDI-USB-интерфейсу

Работать с USB немного сложнее, чем с UART, но библиотека TinyUSB значительно упрощает написание кода.

Raspberry Pico, который принимает информацию о нажатых клавишах от MIDI-клавиатуры является USB-хостом.

  1. В версии TinyUSB, входящей в последний PicoSDK, отсутствует поддержка MIDI USB host, поэтому, если наш проект по версионным контролем Git, добавляем более свежую TinyUSB непосредственно в проект как subtree:

    git subtree add \
    --prefix=lib/tinyusb \
    https://github.com/hathach/tinyusb.git \
    0.20.0 \
    --squash
    
  2. В файле CMakeLists.txt, указываем, где теперь находится библиотека TinyUSB:

    set(PICO_TINYUSB_PATH "${CMAKE_CURRENT_LIST_DIR}/lib/tinyusb/")
    
  3. Подключаем её в том же файле CMakeLists.txt:

    target_link_libraries(pico-midi-synth
    //...
    tinyusb_host
    tinyusb_board
    //…
    )
    
  4. Создаём файл tusb_config.h и конфигурируем в нём USB-стек:

    #ifndef _TUSB_CONFIG_H_
    #define _TUSB_CONFIG_H_
    
    // RP2040
    #define CFG_TUSB_MCU OPT_MCU_RP2040
    
    // USB Device, Full Speed
    #define CFG_TUSB_RHPORT0_MODE OPT_MODE_FULL_SPEED | OPT_MODE_HOST
    
    // EP0
    #define CFG_TUD_ENDPOINT0_SIZE 64
    
    // MIDI Host
    #define CFG_TUH_MIDI 1
    #endif
    
  5. В основной программе объявляем глобальную переменную, которая будет хранить адрес подключаемого USB-устройства, т. е. MIDI клавиатуры:

    volatile uint8_t device = -1;
    
  6. Пишем колбеки для подключения и отключения MIDI-клавиатуры:

    void tuh_midi_mount_cb(uint8_t idx, const tuh_midi_mount_cb_t* mount_cb_data) {
        printf("USB MIDI connected\n");
        device = idx;
    }
    
    void tuh_midi_umount_cb(uint8_t idx) {
        printf("USB MIDI disconnected\n");
        device = -1;
    }
    
    
  7. Теперь мы можем читать информацию, передаваемую MIDI-клавиатурой по USB:

    int main() {
    tusb_init();
    uint8_t midi_packet[4];
    while (true) {
        tuh_task();
        if (device != -1) {
        if (tuh_midi_packet_read(device, midi_packet)) {
            // ...
        }
        }
    }
    }
    

Функция tusb_init() инициализирует USB-стек, функцию tuh_task() необходимо вызывать на каждой итерации цикла, чтобы USB-стек работал. Функция tuh_midi_packet_read() пытается прочитать MIDI USB Event от клавиатуры и возвращает true, если прочитала. Обратите внимание, что в MIDI USB всегда приходит 4 байта, а не от 1 до 3 как в стандартном MIDI-интерфейсе.

Передача данных через I2S-интерфейс

Чтобы использовать I2S-интерфейс в Raspberry Pi Pico из программы на С, подключим репозиторий pico-extras и библиотеку pico_audio_i2s.

Подключение pico-extras к проекту

Подключить pico-extras к проекту несложно:

  1. Помещаем файл pico_extras_import.cmake в корень проекта.

    wget https://raw.githubusercontent.com/raspberrypi/pico-extras/refs/heads/master/external/pico_extras_import.cmake
    
  2. В файлe CMakeLists.txt вставляем следующие строки перед include(pico_sdk_import.cmake):

    set(PICO_EXTRAS_FETCH_FROM_GIT ON)
    cmake_policy(SET CMP0169 OLD)
    
  3. А после include(pico_sdk_import.cmake) в том же файле вставляем:

    include(pico_extras_import.cmake)
    

Теперь можно использовать библиотеки из pico-extras, как и библиотеки из Pico SDK:

```
target_link_libraries(pico-midi-synth
pico_stdlib
pico_audio_i2s)
```

Использование библиотеки pico_audio_i2s

  1. Подключение заголовочного файла:

    #include "pico/audio_i2s.h"
    
  2. Определяем, что будет хранить аудиобуфер

    const struct audio_format format = {.sample_freq = SAMPLE_RATE,
                                        .format = AUDIO_BUFFER_FORMAT_PCM_S16,
                                        .channel_count = 2};
    
  3. Конфигурируем, какие будут использоваться ресурсы Raspberry Pi Pico библиотекой pico_audio_i2s (пины для дата-линийI2S, DMA-канал, PIO State Machine):

    const struct audio_i2s_config const config = {
        .data_pin = I2S_DATA_PIN,
        .clock_pin_base = I2S_CLOCK_PIN_BASE,
        .dma_channel = 0,
        .pio_sm = 0};
    
  4. Конфигурируем, как будет DMA будет работать с аудиобуфером:

    static struct audio_buffer_format producer_format = {.format = &format,
                                                        .sample_stride = 4};
    
  5. Инициализируем I2S:

    const struct audio_format* audio_format = audio_i2s_setup(&format, &config);
    
    if (!audio_format) {
        printf("I2S initialization error!\n");
        return;
    }
    
    struct audio_buffer_pool* audio_pool =
        audio_new_producer_pool(&producer_format, 2, 1024);
        audio_i2s_connect(audio_pool);
        audio_i2s_set_enabled(true);
        printf("Running I2S \n");
    
  6. Теперь можно в цикле при помощи брать буфер из пула struct audio_buffer *buffer = take_audio_buffer(audio_pool, true);, заполнять его данными и отдавать библиотеке при помощи функции give_audio_buffer(audio_pool, buffer), чтобы Pico выводил его на пины Raspberry Pi Pico.

Обработка MIDI-сообщений

Хочется, чтобы унифицировано обрабатывались MIDI-сообщения, приходящие через стандартный интерфейс и через USB, поэтому я написал парсер сообщений, которому нужно побайтно передать MIDI-сообщение. В результате будут вызываться колбеки void midi_note_off(uint8_t note, uint8_t vel) или void midi_note_on(uint8_t note, uint8_t vel) в зависимости от того была нажата или отпущена клавиша.

Использование парсера в случае стандартного MIDI-интерфейса:

if (uart_is_readable(UART_ID)) {
    char ch = uart_getc(UART_ID);
    midi_parser_byte(&midi, ch);
}

Использование парсера в случае стандартного USB MIDI-интерфейса:

uint8_t midi_packet[4];
if (device != -1) {
    if (tuh_midi_packet_read(device, midi_packet)) {
        midi_parser_byte(&midi, midi_packet[1]);
        midi_parser_byte(&midi, midi_packet[2]);
        midi_parser_byte(&midi, midi_packet[3]);
    }
}

Полный код парсера MIDI-сообщений вы можете посмотреть в моём репозитории на GitHub.

Генерирование звучания

Основная логика алгоритма синтеза звука по wavetable

Основная логика — это две функции для событий нажатия и отпускания клавиш и основной аудиоцикл.

  1. midi_note_on() — нажатие ноты:

    • по номеру MIDI-ноты берём готовую частоту из таблицы midi_frequencies,

    • находим свободный голос (get_voice()),

    • вычисляем phase_increment (приращение фазы) для этой частоты,

    • инициализируем голос: сбрасывает фазу в 0, устанавливает velocity, volume и включаем голос (on = true).

  2. midi_note_off() — отпускание ноты:

    • находим голос по номеру ноты,

    • переводим его в режим затухания (on = false).

  3. Основной аудио-цикл:

    • Для каждого семпла:

    • Суммируем (mix) сигнал от всех активных голосов.

    • Каждый голос:

    • Берём семпл из wavetable по индексу get_wawetable_ind(voice->phase).

    • Применяем громкость голоса.

    • Прибавляем phase_increment к фазе.

    • Если нота отпущена — постепенно уменьшаем amp.

    • Ограничиваем микс в диапазон 16-битного аудио (±32767).

    • Дублируем один и тот же семпл на левый и правый канал (моно).

Или в коде:

void midi_note_on(uint8_t note, uint8_t vel) {
    float frequency;
    printf("Note On note = %d velocity = %d\n", note, vel);
    frequency = midi_frequencies[note];
    struct voice* voice = get_voice();
    if (!voice) {
        return;
    }
    voice->phase_increment = get_phase_increment(frequency, SAMPLE_RATE, 32);
    voice->velocity = vel;
    voice->volume = vel;
    voice->id = note;
    voice->phase = 0;
    voice->amp = 1.0;
    voice->on = true;
}

void midi_note_off(uint8_t note, uint8_t vel) {
    printf("Note Off note = %d velocity = %d\n", note, vel);
    struct voice* voice = get_voice_by_id(note);
    if (!voice) {
    return;
    }
    voice->on = false;
}
struct audio_buffer* buffer = take_audio_buffer(audio_pool, true);

int16_t* samples = (int16_t*)buffer->buffer->bytes;
for (uint i = 0; i < buffer->max_sample_count; i++) {
  int64_t mix = 0;
  for (int j = 0; j < MAX_VOICES; j++) {
    struct voice* voice = &voices[j];

    if (voice->volume == 0) continue;

    int16_t sample = wave_table[get_wawetable_ind(voice->phase)];

    int32_t voiced_sample = (int32_t)sample * voice->volume / 128;

    mix += voiced_sample;

    voice->phase += voice->phase_increment;

    if (!voice->on) {
      voice->amp -= RELEASE_SPEED;
      if (voice->amp < 0) voice->amp = 0;
      voice->volume = (int)(voice->velocity * voice->amp);
    }
  }

  mix = (mix * MASTER_GAIN) / 100;
  if (mix > 32767) mix = 32767;
  if (mix < -32767) mix = -32767;

  *samples++ = (int16_t)mix;
  *samples++ = (int16_t)mix;
}

buffer->sample_count = buffer->max_sample_count;
give_audio_buffer(audio_pool, buffer);

Обратите внимание на следующий кусочек кода:

    if (!voice->on) {
      voice->amp -= RELEASE_SPEED;
      if (voice->amp < 0) voice->amp = 0;
      voice->volume = (int)(voice->velocity * voice->amp);
    }

Он нужен для того, чтобы не было щелчков при отпускании клавиш.

Вычисление частоты ноты

Как уже говорилось ранее, частота ноты с номером n (MIDI) рассчитывается по формуле:

f(n) = 440 \times 2^{\frac{n - 69}{12}}

где:

  • f(n) — частота в герцах (Гц)

  • n — номер MIDI-ноты (0–127)

  • 440 Гц — частота ноты A4 (MIDI 69) Если посчитать для каждой MIDI-ноты, то получит следующий массив:

const float midi_frequencies[128] = {
8.1758, 8.6620, 9.1770, 9.7227, 10.3009, 10.9134, 11.5623, 12.2499,
12.9783, 13.7500, 14.5676, 15.4339, 16.3516, 17.3239, 18.3540, 19.4454,
20.6017, 21.8268, 23.1247, 24.4997, 25.9565, 27.5000, 29.1352, 30.8677,
32.7032, 34.6478, 36.7081, 38.8909, 41.2034, 43.6535, 46.2493, 48.9994,
51.9131, 55.0000, 58.2704, 61.7354, 65.4064, 69.2957, 73.4162, 77.7817,
82.4069, 87.3071, 92.4986, 97.9989, 103.8262, 110.0000, 116.5409, 123.4708,
130.8128, 138.5913, 146.8324, 155.5635, 164.8138, 174.6141, 184.9972, 195.9977,
207.6523, 220.0000, 233.0819, 246.9417, 261.6256, 277.1826, 293.6648, 311.1270,
329.6276, 349.2282, 369.9944, 391.9954, 415.3047, 440.0000, 466.1638, 493.8833,
523.2511, 554.3653, 587.3295, 622.2540, 659.2551, 698.4565, 739.9888, 783.9909,
830.6094, 880.0000, 932.3275, 987.7666, 1046.5023, 1108.7305, 1174.6591, 1244.5079,
1318.5102, 1396.9129, 1479.9777, 1567.9817, 1661.2188, 1760.0000, 1864.6550, 1975.5332,
2093.0045, 2217.4610, 2349.3181, 2489.0159, 2637.0205, 2793.8259, 2959.9554, 3135.9635,
3322.4376, 3520.0000, 3729.3101, 3951.0664, 4186.0090, 4434.9221, 4698.6362, 4978.0317,
5274.0410, 5587.6517, 5919.9108, 6271.9270, 6644.8752, 7040.0000, 7458.6202, 7902.1328,
8372.0180, 8869.8443, 9397.2724, 9956.0635, 10548.0820, 11175.3034, 11839.8215, 12543.8540
};

Заполнение wavetable

Заполнение wavetable, содержащую один период волны, представленной синусоидой, выглядит следующим образом:

void init_wave_table(void) {
for(int i = 0; i < WAVE_TABLE_SIZE; i++) {
    float angle = 2.0f * M_PI * i / WAVE_TABLE_SIZE;
    wave_table[i] = (int16_t) (sinf(angle) * 32767.0f);
}
}

Определение приращения фазы осциллятора

Приращение фазы (\Delta\phi) для n-битного фазового аккумулятора рассчитывается по формуле:

\Delta\phi = \frac{f \times 2^n}{f_s}

где:

  • \Delta\phi — приращение фазы за один семпл

  • f — желаемая частота звука в Гц

  • f_s — частота дискретизации (sample rate) в Гц

  • n — разрядность фазового аккумулятора

В коде это выглядит так:

uint32_t get_phase_increment(float freq, uint32_t sample_rate, uint32_t n) {
    return (uint32_t)(freq * (1ULL << n) / sample_rate);
}

Определение индекса в wavetable для целочисленной фазы

Для нашей wavetable:

uint32_t get_wawetable_ind(uint32_t phase) {
    return phase >> 21;
}

phase >> 21 берёт старшие 11 бит 32-битного фазового аккумулятора, чтобы получить индекс в wavetable размером 2048 семплов.

Использование двух ядер Raspberry Pi Pico

У Raspberry Pi Pico два ядра и логично одно использовать для обработки ввода с MIDI-клавиатуры, а второе ядро для синтеза звука.

Для использования двух ядер RP2040 нужно подключить библиотеку pico_multicore в файле CMakeLists.txt.

int main() {

    stdio_init_all();

    init_wave_table();

    for (int i = 0; i < MAX_VOICES; i++) {
        voices[i].velocity = 0;
    }

    multicore_launch_core1(audio_core);

    main_loop();
}

Полезные ссылки

  1. Мой репозиторий с проектом синтезатора на GitHub

  2. I2S bus specification

  3. MIDI 1.0 Detailed Specification

  4. Universal Serial Bus Device Class Definition for MIDI Devices

  5. Pico WAV Player

  6. Pico exras

  7. Даташит для PCM5102a

  8. Даташит для MAX98357a

Выводы

В статье я рассмотрел принципы и понятия цифровой генерации звука, а так же как реализовать простейший синтезатор на реальном железе.

Конечно, это далеко не всё, что используется в современных синтезаторах. Я намеренно не стал рассказывать об огибающих (ADSR), LFO, фильтрах, эффектах и других возможностях, чтобы не перегружать статью и сосредоточиться на базовых принципах генерации звука.

Сейчас у вас есть программный синтезатор, которым можно играть музыку с колебаниями, приближенными к идеальному синусу.

Изучение синтеза цифровой музыки является отличным введением в цифровую обработку сигналов (Digital Signal Processing или DSP).

Этот подход эффективен, так как он связывает абстрактные математические концепции с мгновенным слуховым результатом, что даёт дополнительную мотивацию.

В следующей статье я планирую рассказать о больше синтезе и об использовании музыкальных семплов для получения звучания, как у реальных инструментов. Так что если интересно, не переключайтесь.

© 2026 ООО «МТ ФИНАНС»

Комментарии (0)