Скибиди-бипер — асинхронная полифоническая однобитная музыка на ESP32 без ЦАП

в 14:05, , рубрики: mp3, PIS-OS, timeweb_статьи, дисплей, звук, ретро, синтез звука, физика, цап, Часы
Скибиди-бипер — асинхронная полифоническая однобитная музыка на ESP32 без ЦАП - 1

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

В процессе выяснилось, что пьезоэлемент был припаян к той ноге МК, на которой ЦАП отсутствует. Впрочем, если бы я хотел будильник, который звучит как mp3 — просто пользовался бы мобильником, так что самое время вспоминать наследие демосцены и делать самый настоящий однобитный драйвер звука!

❯ Сначала было ‭«До‭»

Конечно, можно музыку делать и при помощи однотонального бипера. Для этого хорошо подходит функция ledcWriteTone из фреймворка Arduino на ESP32. Эта функция просто рассчитывает параметры встроенного ШИМ-контроллера так, чтобы на выходе получилась заданная частота — но полифонию таким образом не получить.

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

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

Долгое время в прошивке был одноканальный секвенсор, прибитый гвоздями к биперу на базе этой самой функции, а мелодии напоминали музыку, играемую на PC Speaker.

NB: Здесь и далее звук вставлен в виде записей на SoundCloud, а если он у вас не грузится — продублирован ссылками на файл.

DJ Brisk & Trixxy — Eye Opener на бипере: потому что будильник тоже в своём роде открывашка для глаз :-)

❯ Как работает однобитный звук

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

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

Нижняя эпюра представляет собой сумму верхних двух

Нижняя эпюра представляет собой сумму верхних двух

Открытием для меня стал тот факт, что для однобитного звука принцип ничуть не отличается! Мы имеем несколько генераторов прямоугольных импульсов и просто их суммируем. Так как разрешение у нас всего лишь 1 бит, то и сложение можно использовать логическое — то есть, двоичное «ИЛИ». В таком контексте оно будет работать просто как сложение с насыщением, т.е. «клипирование» нам даётся автоматически.

Голубым и бордовым отмечены два тона, фиолетовым — суммарная импульсов после логического сложения

Голубым и бордовым отмечены два тона, фиолетовым — суммарная импульсов после логического сложения

Если подумать, то это отчасти кажется очевидным — генератор с меньшей длиной волны, т.е. с более высоким тоном, будет слышно в промежутках, когда генератор более низкого тона «молчит», выдавая логический 0. Эдакое сверхбыстрое арпеджио :-)

Дальше же срабатывает инерционность последующих стадий системы — от физической инерции динамика, уха, и воздуха между ними; и до банальной инерции восприятия. Мозг принимает от уха эти интермодуляции, которые получаются в результате такого грубого смешения сигналов, и «по привычке» считает звучащее за отдельные тона аккорда.

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

Осциллограмма аккорда в одном из итоговых треков

Осциллограмма аккорда в одном из итоговых треков

Казалось бы, элементарно решается через связку digitalWrite() и delayMicroseconds() на обычной ардуине. Но увы, когда микроконтроллеру нужно заниматься чем-то ещё более полезным — считать время, например, или погоду показывать — такой подход уже не канает.

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

❯ I²S для звука — ЦАП необязателен

ESP32, на базе которого я сделал часы, имеет аппаратный трансивер I²S с DMA. Конечно, встроенный I²S-ЦАП там тоже есть, но по уже озвученным причинам пользоваться им мы не будем. Однако аппаратный драйвер шины можно подцепить на любые пины — в том числе и на тот, на который у меня повешена пищалка.

Сам же протокол I²S чем-то напоминает SPI — линия тактового сигнала плюс линия данных, и вдогонку линия чётности для стерео:

wdwd, CC-BY 3.0, https://commons.wikimedia.org/w/index.php?curid=16579640

wdwd, CC-BY 3.0, https://commons.wikimedia.org/w/index.php?curid=16579640

Раз оно заточено на звук, то и стабильность выдачи данных должна быть шикарной, а наличие DMA означает, что мы можем просто записать кусок данных в буфер и на какое-то время забыть про эту задачу вообще — до тех пор, пока буфер не опустошится. Процессор при этом может делать что угодно, а трансивер I²S будет сам читать данные из оперативной памяти по мере необходимости.

Для непрерывности же достаточно иметь два-три таких буфера, и пока один «выдрыгивается», мы заполняем второй, сразу же отдаём назад и ждём прерывания о том, что первый закончился. Заполняем уже его, и далее по кругу. Впрочем, об этой логике нам даже думать не нужно — в стандартной библиотеке ESP32 это всё уже реализовано до нас.

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

void WaveOut::init_I2S(gpio_num_t pin) {
    if(WaveOut::i2sInited) return;

    i2s_config_t i2s_config = {
        .mode = I2S_MODE_MASTER | I2S_MODE_TX, // <- работа на передачу
        .sample_rate = SAMPLE_RATE, // <- экспериментально подобрано равным 22050
        .bits_per_sample = I2S_BITS_PER_SAMPLE_8BIT, // <- 8 бит на сэмпл, для простоты работы с буферами
        .channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT, 
        .communication_format = I2S_COMM_FORMAT_STAND_MSB, // <- передаём данные начиная с наибольшего бита, слева направо
        .intr_alloc_flags = ESP_INTR_FLAG_LEVEL2,
        .dma_buf_count = DMA_NUM_BUF, // <- сколько буферов для очереди создавать, мне хватило трёх
        .dma_buf_len = DMA_BUF_LEN, // <- длина одного буфера, мне хватило 512 байт
        .use_apll = true, // <- использует APLL для точности тайминга, в конкретно этом случае не похоже, что влияет на что-то
        .bits_per_chan = I2S_BITS_PER_CHAN_DEFAULT // <- битов на канал столько же, сколько на сэмпл, т.е. 8 бит
    };

    // Неиспользуемые пины не указываем, однако инициализировать этот код нужно первым,
    // т.к. на ESP32 состояние pinmux в этот момент всё равно испортится
    i2s_pin_config_t pincfg = {
        .mck_io_num = I2S_PIN_NO_CHANGE,
        .bck_io_num = I2S_PIN_NO_CHANGE,
        .ws_io_num = I2S_PIN_NO_CHANGE,
        .data_out_num = pin,
        .data_in_num = I2S_PIN_NO_CHANGE,
    };

    i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
    i2s_set_pin(I2S_NUM, &pincfg);
    // Создаём таску, которая будет подготавливать буферы для вывода
    xTaskCreate(
        task,
        "WaveOut",
        4096,
        nullptr,
        pisosTASK_PRIORITY_WAVEOUT, // <- (configMAX_PRIORITIES - 1)
        &hTask
    );

    WaveOut::i2sInited = true;
}

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

void WaveOut::task(void*) {
    // Сюда рендерит источник
    static uint8_t chunk[RENDER_CHUNK_SIZE+1] = { 0x0 };
    // Нулевые байты на случай если ничего не нарендерили, чтобы не греть процессор отдачей буферов нулевой длины
    static const uint8_t null[RENDER_CHUNK_SIZE+1] = { 0 };
    // Сколько записали в DMA буфер, не используется — см. ниже
    static size_t out_size = 0;
  
    while(1) {
        size_t total = 0;
        for(int i = 0; i < CHANNEL_COUNT; i++) {
            // Просим генератор отрендерить кусок звука в буфер
            size_t generated_bytes = callback[i](chunk, RENDER_CHUNK_SIZE);
            // Если текущий генератор сделал больше семплов, чем прошлый, то выводим все
            if(generated_bytes > total) total = generated_bytes;
        }
        if(total > 0) {
            // Записываем в DMA-буфер, блокируя поток до конца записи — именно поэтому out_size нам особо и не нужен
            i2s_write(I2S_NUM, chunk, total, &out_size, portMAX_DELAY);
            // Очищаем буфер перед следующей итерацией
            memset(chunk, 0, RENDER_CHUNK_SIZE+1);
        } else {
            // Ничего не было отрендерено, записываем полный буфер нулей
            i2s_write(I2S_NUM, null, RENDER_CHUNK_SIZE, &out_size, portMAX_DELAY);
        }
        taskYIELD(); // <- передаём управление следующей по приоритету задаче
    }
}

❯ Генерируем тон

Теперь у нас есть способ вывести поток однобитных сэмплов на пин микроконтроллера, однако же нужно их сгенерировать, чтобы услышать что-то осмысленное.

Самым простым будет сгенерировать прямоугольные импульсы. Для этого нужно определить, по сколько единичных и нулевых битов нужно выдавать. Их количество можно посчитать как отношение итогового битрейта к частоте:

lambda=frac{f_s}{f}, text{ если }f_s ggg f

В моём случае, при настройке трансивера I²S на 44100 Гц (медленнее почему-то не заводится), 8 бит на канал, стерео, битрейт получился:

f_s=f_{I^2S} * s * c=44100 * 8 * 2=705600 text{ бит/с}

Тогда, например, для тона «ля» в 440 Гц, длина импульса у нас получится:

lambda=frac{f_s}{f}=frac{705600}{440}=1604text{ бит}

То есть на выход нужно будет отправить сначала 1604 бита в состоянии лог. 1, потом 1604 бита в состоянии лог. 0, и так до посинения — а на выходе будет меандр в приблизительно 440 Гц. Попробуем сгенерировать прямоугольную волну, просто заполняя нужное количество битов через уже известное нам логическое «ИЛИ»:

size_t SquareGenerator::generate_samples(void* buffer, size_t length, uint32_t want_samples_) {
    if(!active || wavelength == 0) return 0; // <- генератор выключен

    // Считаем, сколько бит держать в состоянии лог. 1
    // с учётом скважности, где скважность 2 соответствует меандру (50%)
    int bits_high = wavelength / abs(duty);
    if(duty < 0) bits_high = wavelength - bits_high;
  
    uint8_t* buff = (uint8_t*) buffer;
    // Если не было указано, сколько бит сгенерировать, заполняем весь буфер
    uint32_t want_samples = want_samples_ == 0 ? (length * 8) : std::min(want_samples_, length * 8);
    
    size_t idx = 0;
    int bit = 7;

    // Цикл по битам буфера
    for(int s = 0; s < want_samples; s++) {
        bool state = (phase < bits_high); // <- текущее состояние бита
        idx = s / 8; // <- индекс байта, в котором находится текущий бит
        bit = 8 - (s % 8); // <- индекс бита внутри байта, при нумерации слева направо (MSB = 0, LSB = 7)
        if(state) {
            buff[idx] |= (1 << bit); // <- если бит нужно "включить", то так и делаем
        }
        phase = (phase + 1) % wavelength; // <- обновляем текущую фазу генератора
    }
    
    return idx + 1; // <- возвращаем количество сгенерированного с округлением до байта
}

❯ Генерируем шум

Захотелось также иметь возможность генерировать шумовую дорожку в качестве простейшего ритм-инструмента. После многих экспериментов остановился на генераторе шума из знакомого уже нам AY-3-8910, который я, ничтоже сумняшеся, передрал из MAME. Не очень сильно понимаю, как он работает, поэтому просто приведу его код как есть. Звучит, по крайней мере, весьма похоже на тот, что на спектруме был :-)

size_t NoiseGenerator::generate_samples(void* buffer, size_t length, uint32_t want_samples_) {
    if(!active || wavelength == 0) return 0;

    uint8_t* buff = (uint8_t*) buffer;
    uint32_t want_samples = want_samples_ == 0 ? (length * 8) : std::min(want_samples_, length * 8);
    size_t idx = 0;
    int bit = 7;

    for(int s = 0; s < want_samples; s++) {
        idx = s / 8;
        bit = 8 - (s % 8);
        if(state && (rng & 1) > 0) {
            buff[idx] |= (1 << bit);
        }
        phase = (phase + 1) % wavelength;
        if(phase == 0) {
            state ^= 1;
            if (state) {
                rng ^= (((rng & 1) ^ ((rng >> 3) & 1)) << 17);
                rng >>= 1;
            }
        }
    }
    
    return idx + 1;
}

❯ Играем сэмплы

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

Для экономии места мы даже можем не хранить сырые PCM отсчёты, а сделать эдакое RLE-сжатие: сначала число единичных битов, потом число нулевых, потом опять единичных, и т.д.

На питоне был наговнокожен конвертер, реализующий пороговый фильтр, а затем сохраняющий данные именно в таком виде. Так как он настолько простой, что даже не проверяет, является ли подсунутое ему файлом WAV-формата с параметрами ‭«8 бит, моно, 8 кГц‭», то прячу его под спойлер — если вам нужно решить такую же задачу, лучше напишите что-то своё :-)

Скрытый текст
#-*- coding: utf-8 -*-

import sys

MARGIN = 4 # Запас гистерезиса от медианного значения в файле

fname = sys.argv[1] # Путь ко входному файлу в формате WAV/8kHz/8bit
sname = sys.argv[2] # Название итоговой переменной с данными
oname = sys.argv[3] if len(sys.argv) >= 4 else None # Путь к выходному файлу WAV/8kHz/8bit для предпрослушивания
sdata = open(fname, 'rb').read()
outf = None
if oname is not None:
    outf = open(oname, 'wb')
    outf.write(sdata[:0x28])

# Пропускаем заголовок по фиксированному смещению
sdata = sdata[0x28::]
i = 0
min = 999
max = 0
sts = 0xFF
last_sts = 0xFF
rle_buf = [0]

def median(data):
    x = list(data)
    x.sort()
    mid = len(x) // 2
    return (x[mid] + x[~mid]) / 2.0

# Находим медианное значение в файле и по нему определяем пороги для лог. 1 и лог. 0
med = median(sdata)
print("Median", med)
HIGH = med + MARGIN
LO = med - MARGIN

while i < len(sdata):
    curSample = sdata[i]
    if curSample >= HIGH:
        sts = 255
    elif curSample <= LO:
        sts = 1
    if curSample < min and curSample > 0:
        min = curSample
    if curSample > max and curSample > 0:
        max = curSample
    if outf is not None:
        outf.write(bytes([sts]))
    if sts != last_sts: # Если бит поменялся, добавляем новое значение в массив
        rle_buf.append(0)
        last_sts = sts
    if rle_buf[-1] == 255:
        rle_buf.append(0) # Если байт в массиве переполнился, а бит всё ещё не менялся, добавляем
        rle_buf.append(0) # запись о нуле бит противоположной полярности
    rle_buf[-1] += 1
    i += 1

print(f"static const uint8_t {sname}_rle_data[] = {{" + str(rle_buf)[1::][:-1:] + "};")
print(f"static const rle_sample_t {sname} = {{ .sample_rate = 8000, .root_frequency = 524 /* C5 */, .rle_data = {sname}_rle_data, .length = {len(rle_buf)} }};")

Затем аналогичным образом воспроизводим этот сэмпл, примешивая в буфер. При этом следует не забыть растянуть сэмпл до того битрейта, с которым мы буфер выводим. Понижение тональности делается путём ‭«растяжения‭» битов, повышение, соответственно, ‭«сжатием‭».

Конкретно за алгоритм изменения тональности не ручаюсь — его проверять не доводилось. Но один к одному этот генератор сэмплы играет корректно.

size_t Sampler::generate_samples(void * buffer, size_t length, uint32_t want_samples_) {
    if(!active || waveform == nullptr || waveform->length == 0 || waveform->rle_data == nullptr)
        return 0;

    uint8_t* buff = (uint8_t*) buffer;
    uint32_t want_samples = want_samples_ == 0 ? (length * 8) : std::min(want_samples_, length * 8);
    size_t idx = 0;
    int bit = 7;

    for(int s = 0; s < want_samples; s++) {
        idx = s / 8;
        bit = 8 - (s % 8);
        
        if(state) {
            buff[idx] |= (1 << bit);
        }

        if(stretch_factor == 1 || (s > 0 && (s % stretch_factor) == 0)) {
            if(skip_factor > remaining_samples) {
                playhead = (playhead + std::max(skip_factor / 8, 1)) % waveform->length;
                remaining_samples = waveform->rle_data[playhead] - ((skip_factor % 8) - remaining_samples);
                state ^= 1;
            } else {
                remaining_samples -= skip_factor;
            }

            if(remaining_samples == 0) {
                playhead = (playhead + 1) % waveform->length;
                remaining_samples = waveform->rle_data[playhead];
                state ^= 1;
            }
        }
    }
    
    return idx + 1;
}

❯ Собираем воедино и делаем музыку

После этого осталось дело за малым — иметь возможность управлять этими генераторами во времени. Для этого понадобится написать простейший секвенсор. Сначала определим типы данных для задания мелодий:

typedef enum melody_item_type {
    FREQ_SET, // <- Установить частоту генератора, или 0 = заткнуть его
    DUTY_SET, // <- Установить скважность генератора
    DELAY, // <- Задержка в миллисекундах
    LOOP_POINT_SET, // <- Установить точку зацикливания мелодии
    SAMPLE_LOAD, // <- Загрузить сэмпл в сэмплер
    MAX_INVALID
} melody_item_type_t;

typedef struct melody_item {
    const melody_item_type_t command : 4; // <- Команда
    const uint8_t channel : 4; // <- Канал
    const int argument1; // <- Аргумент для команды
} melody_item_t;

typedef struct melody_sequence {
    const melody_item_t * array; // <- Данные мелодии
    size_t count; // <- Количество записей в массиве данных мелодии
} melody_sequence_t;

Сам секвенсор же будет генерировать биты для вывода, запрашивая поочерёдно у каждого из своих ‭«голосов‭» примешать сигнал в буфер.

size_t NewSequencer::fill_buffer(void* buffer, size_t length) {
    if(!is_running) return 0;
    // Если задержка закончилась, обработать следующие команды в треке до следующей задержки
    if(remaining_delay_samples == 0) process_steps_until_delay();

    size_t generated = 0;
    uint32_t want_samples = std::min(length * 8, (size_t) remaining_delay_samples);
    // Рендерим в буфер все каналы поочерёдно
    for(int i = 0; i < CHANNELS; i++) {
        size_t cur = voices[i]->generate_samples(buffer, length, want_samples);
        if(cur > generated) generated = cur;
    }

    // Уменьшить счётчик текущей задержки на число битов, которое мы сгенерировали
    remaining_delay_samples -= want_samples;

    return generated;
}


void NewSequencer::process_steps_until_delay() {
    if(!is_running) return;

    // Дошли до конца трека
    if(pointer >= sequence->count) {
        // Если играем бесконечно или нужны ещё повторения
        if(repetitions == -1 || repetitions > 0) {
            // Уменьшаем счётчик повторов и переходим на точку зацикливания
            if(repetitions > 0) repetitions--;
            pointer = loop_point;
            process_steps_until_delay();
            return;
        } else if(repetitions == 0) {
            // Заканчиваем играть мелодию
            stop_sequence();
            return;
        }
    }

    const melody_item_t * cur_line = &sequence->array[pointer];
    switch(cur_line->command) {
        case FREQ_SET:
            voices[cur_line->channel]->set_parameter(ToneGenerator::Parameter::PARAMETER_FREQUENCY, cur_line->argument1);
            break;
        case DUTY_SET:
            voices[cur_line->channel]->set_parameter(ToneGenerator::Parameter::PARAMETER_DUTY, cur_line->argument1);
            break;
        case LOOP_POINT_SET:
            loop_point = pointer + 1;
            break;
        case DELAY:
            // Считаем задержку в битах, умножая число миллисекунд на число битов за миллисекунду
            remaining_delay_samples = cur_line->argument1 * WaveOut::BAUD_RATE / 1000;
            break;
        case SAMPLE_LOAD:
            voices[cur_line->channel]->set_parameter(ToneGenerator::Parameter::PARAMETER_SAMPLE_ADDR, cur_line->argument1);
            break;
        default:
            break;
    }

    pointer++;

    if(cur_line->command != DELAY) {
        // Если ещё не дошли до задержки, обрабатываем следующую команду
        process_steps_until_delay();
    }
}

И напоследок, для упрощения написания мелодий, пишем конвертер из MIDI в секвенции. Конечно, итоговый результат порой требует доработки напильником, но всё же такой инструмент сильно помогает. Для чтения файлов я использовал библиотеку MIDO, а для получения частоты по MIDI-нотам — freq-note-converter.

Скрытый текст
#!/usr/bin/env python3

from sys import argv
from mido import MidiFile
import freq_note_converter

mid = MidiFile(argv[1])
name = argv[2]

ended = False

class Event():
    def __init__(self, kind, chan, arg):
        self.kind = kind
        self.chan = chan
        self.arg = arg

    def __str__(self):
        return f"    {{{self.kind}, {str(self.chan)}, {str(int(self.arg))}}},"

class Comment():
    def __init__(self, s):
        self.content = s
        self.kind = "REM"

    def __str__(self):
        return f"    /* {self.content} */"

evts = []

# Находит предыдущую команду отключения звука на канале, если с тех пор не было ни одной команды задержки
def prev_note_off_event(chan):
    for i in range(1,len(evts)+1):
        e = evts[-i]
        if e.kind == "FREQ_SET" and e.arg == 0 and e.chan == chan:
            return e
        elif e.kind == "DELAY":
            return None
    return None

for msg in mid:
    # Если нужна задержка, вставляем соответствующую команду
    if msg.time > 0.005:
            evts.append(Event("DELAY", 0, msg.time * 1000))
    # Событие нажатия или отпускания ноты
    if msg.type == "note_on" or msg.type == "note_off":
        if msg.type == "note_on" and msg.velocity > 0:
            # Событие нажатия ноты (note_on с усилием больше 0, если усилие = 0, то это то же самое, что и note_off)
            existing_evt = prev_note_off_event(msg.channel)
            if existing_evt is not None:
                # Если с прошлого отключения звука на этом канале не было ни одной задержки, то записываем частоту в то событие
                existing_evt.arg = freq_note_converter.from_note_index(msg.note).freq
            else:
                # Иначе создаём новое событие установки частоты
                evts.append(Event("FREQ_SET", msg.channel, freq_note_converter.from_note_index(msg.note).freq))
        else:
            # Создаём событие установки частоты = 0, т.е. отключение звука
            evts.append(Event("FREQ_SET", msg.channel, 0))
    elif msg.type == "end_of_track":
        if ended:
            raise Exception("WTF, already ended")
        ended = True
    elif msg.type == "marker":
        # Добавляем комментарий
        evts.append(Comment(msg.text))
        if msg.text == "LOOP":
            # Если комментарий LOOP, устанавливаем в этом месте точку зацикливания трека
            evts.append(Event("LOOP_POINT_SET", 0, 0))
    elif msg.type == "control_change":
        if msg.control == 2:
            # Control Change #2 используем для изменения скважности генератора
            evts.append(Event("DUTY_SET", msg.channel, msg.value))
        

print("static const melody_item_t "+name+"_data[] = {")
for e in evts:
    print(str(e))
print("};")

print("const melody_sequence_t "+name+" = MELODY_OF("+name+"_data);")

Так как на звание нового Тима Фоллина я (пока :-) не претендую, то просто понатыкал что-то по мелочи на синтезаторе и раскидал в уже привычном редакторе Sekaiju:

Скибиди-бипер — асинхронная полифоническая однобитная музыка на ESP32 без ЦАП - 10

Важно следить, чтобы было не больше одной ноты на канал, так как каждый генератор у нас генерирует только один тон. Возможно, когда-то потом я это автоматизирую в том же скрипте конвертера, но не сейчас :-)

❯ Слушаем!

После добавления всех этих мелодий в прошивку заливаем её в часы и слушаем результат:

KOTOKO - Re-sublimity

→ MP3 ←

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

PinocchioP - God-ish (神っぽいな)

→ MP3 ←

Здесь трек уже никак не клеится без голосовых вставок, которые есть в оригинале — для этого и был добавлен сэмплер. Оригинал, MIDI, код

Hiroyuki Oshima - The Ark Awakes From The Sea Of Chaos

→ MP3 ←

Это был первый трек, подобранный для нового секвенсора. Как по мне, ‭«дроп‭» в переходе от биперной партии к многоканальной звучит шикарно. Оригинал, MIDI, код

Timbaland - Give It To Me

→ MP3 ←

Заевший ещё во времена МУЗ-ТВ и прочего тем, кто постарше, а младшему поколению уже как мелодия из вирусного видео ‭«Скибиди-туалет‭», откуда и пошло название для статьи %) Оригинал, MIDI, код

A.M. — Arise

→ MP3 ←

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

❯ Заключение

Вот таким совершенно нехитрым образом можно генерировать биперный звук на ESP32, практически не занимая процессор, для тех случаев, когда полноценного ЦАПа слишком много, а ledcWriteTone — слишком мало.

Посмотреть код можно на гитхабе, конкретно аудио в src/sound. Со временем хотелось бы пооптимизировать всё это дело и как-то ещё упростить написание мелодий. Ну а почитать прочие ворклоги работы над этим бешеным будильником, среди прочих скитаний и туевой хучи фоток еды и Мику вы можете у меня в телеграм-канале :-)

Также отдельное спасибо @NightRadio за наводки и помощь в написании движка, сам бы я вряд ли додумался, что это можно сделать так просто.

Автор: vladkorotnev

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js