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

Однако же за это время 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} = 1604\text{ бит}

То есть на выход нужно будет отправить сначала 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:

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

❯ Слушаем!

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

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 за наводки и помощь в написании движка, сам бы я вряд ли додумался, что это можно сделать так просто.

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud  в нашем Telegram-канале 

Перейти ↩

? Читайте также:

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


  1. iShrimp
    26.08.2024 17:43
    +3

    Прикольно. Не пробовали ли вы сочинять такую чиптюн-музыку в натуральном строе?

    Конечно, эти биения аккордов, вкупе с тембром square wave, придают музыке определённый шарм. Но интересно, как она будет звучать, если все ноты настроить в точные соотношения (3/2 - квинта, 5/4, 6/5, 7/6 - терции и т.д.).

    Вспомнил свои первые эксперименты со звуком в виде программы, игравшей последовательность частот из текстового файла (в роли источника звука выступал, конечно, PC Speaker). Музыка сочинялась просто: в качестве основного тона берём число, хорошо делящееся на 2, 3, 5 (например, 240 Гц), и создаём новые ноты, умножая/деля на 2, 3, 5 и их отношения (для удобства, чтобы понимать, где какая нота, рисуем на бумаге сетку степеней 3 - квинты/кварты и 5 - терции/сексты).

    Пример

    240, 360, 540, 360, 600, 540, 360, 540,

    240, 360, 540, 360, 600, 540, 360, 540,

    200, 300, 450, 300, 480, 450, 300, 450,

    200, 300, 450, 300, 480, 450, 300, 450,

    320, 480, 720, 480, 640, 480, 320, 480,

    360, 540, 810, 540, 720, 540, 360, 540,

    240, 360, 540, 360, 600, 540, 360, 540,

    240, 360, 540, 360, 600, 540, 360, 540,

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

    Но потом стали популярны различные трекеры, и находились умельцы, которые в них писали музыку в Just intonation с помощью команд файн-тюна. То ещё извращение, но в результате могло получиться очень плавное и чистое звучание. Пример: https://www.youtube.com/watch?v=5I9tgRfDsv4


    1. iShrimp
      26.08.2024 17:43
      +4

      При работе с натуральным строем приходится постоянно иметь дело с коммами. В приведённом примере есть две ноты - 810 Гц (3 квинты вверх от основного тона) и 200 Гц (малая терция вниз от основного тона), различающиеся после приведения октав на комму. Они находятся в разных местах гармонической сетки и выполняют разные функции.

      Вот пример гармонической сетки:

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

      Пример настройки

      Как известно, натуральная квинта (701,955 центов) примерно на 2 цента шире равномерно темперированной (700 центов). Сужая квинту на определённую долю коммы (которая составляет примерно 21,5 центов) и подтягивая большую терцию до совпадения с 4 квинтами (минус октавы), мы получаем строи, более близкие или к пифагорейскому, или к классическому среднетоновому (1/4 коммы).

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

      F +2

      C 0

      G -2

      D -4

      A -6

      E -8

      B -10

      F# -12

      C# -14

      G# -16

      D# -18

      A# -20

      Попробовав поиграть в таком строе в До-мажоре, можно ощутить некоторое смягчение терций и секст в сравнении со стандартным строем. Также вы заметите, что минорные аккорды на I, IV, V ступенях стали звучать иначе - они превратились в субминорные, выражающиеся отношением 6:7:9 вместо 10:12:15. Интересно звучит прогрессия Fmajor - Fsubminor - Cmajor.

      Для сочинения JI музыки на ПК, к сожалению, нет какого-то удобного и доступного JI редактора/секвенсера. Я для редких экспериментов использую Reaper с плагином Alt-tuner. Но у программистов микроконтроллеров в этом отношении руки совершенно развязаны - можно использовать любые техники и алгоритмы синтеза, которые будут "по зубам" данному МК. Даже на простейших МК, оснащённых только ШИМом, есть два канала управления звуком: частота, и коэффициент заполнения, от которого зависит тембр звука.

      При значении КЗ = 0.5 в звуковом сигнале подавлена каждая вторая гармоника, и спектр состоит только из нечётных гармоник. Такой тембр подчёркивает нечётные соотношения тонов (3/1, 5/3, 7/5 и др.), и в частности хорошо подходит для экзотических безоктавных строёв типа Bohlen-Pierce.

      При значении КЗ = 0.33 в звуковом сигнале подавлена каждая третья гармоника и усилены гармоники под номерами 1, 2, 4, 5, 7, 8 и т.д. Такой тембр интересен как сам по себе, так и может использоваться для подчёркивания терций и подавления биений квинт/кварт в среднетоновых строях. И так далее.


      1. Refridgerator
        26.08.2024 17:43
        +1

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

        \frac{a^{n+1} (a \sin (\pi  (n+1) x)-\sin (\pi  (n+2) x))+\sin (\pi  x)}{a^2-2 a \cos (\pi  x)+1}

        при n=4 эквивалентна этой

        a^4 \sin (5 \pi  x)+a^3 \sin (4 \pi  x)+a^2 \sin (3 \pi  x)+a \sin (2 \pi  x)+\sin (\pi  x)

        Но до практического применения её в собственном синтезаторе дело так и дошло.


    1. vladkorotnev Автор
      26.08.2024 17:43

      Попробовать можно, но я не композитор толком, а если существующие записи потранспонировать во всё это дело — не факт, что хорошо звучать будет


    1. ainoneko
      26.08.2024 17:43

      Я помню, в журнале в то время примерно была резидентная программа на ассемблере, которая "К Элизе" исполняла :)


  1. garbagecollected
    26.08.2024 17:43
    +2

    Послушал результаты и нахлынули воспоминания тех теплых дней, когда вручную вводили мелодии в Nokia 3310...


    1. vladkorotnev Автор
      26.08.2024 17:43

      Была мысль сделать редактор прям на будильнике, но с четырёх клавиш писать mml как-то уж дюже не с руки получится :/


  1. checkpoint
    26.08.2024 17:43
    +2

    У Вас получились часы Montana из 80-х - настоящая легенда.

    Козырная Montana

    PS: PinocchioP получилась очень даже... но без Bad Apple тема раскрыта не полностью! ;)


  1. RolexStrider
    26.08.2024 17:43
    +1

    Похоже на хорошие биперные треки для ZX-Spectrum. Особенно понравилась PinocchioP - God-ish. Единственное что - во всех мелодиях не хватает перкуссии (насколько я знаю, в Spectrum-овских beeper-ных треках достигается вставками ОЧЕНЬ коротких фрагментов "белого шума", сл.напр.саундтрек к Savage 2 - для меня практически вершина 1-bit tune


    1. vladkorotnev Автор
      26.08.2024 17:43

      Ну Саваж 2 это как раз Тим Фоллин — лучше него мало кто может сделать


      1. RolexStrider
        26.08.2024 17:43

        Savage 2 - это Дэвид Уиттаккер. Но Тим Фоллин тоже не менее крут - у него свой очень узнаваемый стиль. Саундтрек Chronos как классический пример.


        1. vladkorotnev Автор
          26.08.2024 17:43

          Точно, как раз с Chronos и перепутал


    1. vladkorotnev Автор
      26.08.2024 17:43
      +1

      Хотя, вот последний трек уже попробовал перкуссией разбавить, вроде неплохо вышло:

      Оригинал